feat(server) user-defined storage structure (#1098)

[Breaking] newly uploaded file will conform to the default structure of `{uploadLocation}/{userId}/year/year-month-day/filename.ext`
This commit is contained in:
Alex 2022-12-16 14:26:12 -06:00 committed by GitHub
parent 391d00bcb9
commit c754c860fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 1892 additions and 173 deletions

View File

@ -64,6 +64,8 @@ doc/SystemConfigApi.md
doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
doc/SystemConfigOAuthDto.md
doc/SystemConfigStorageTemplateDto.md
doc/SystemConfigTemplateStorageOptionDto.md
doc/TagApi.md
doc/TagResponseDto.md
doc/TagTypeEnum.md
@ -152,6 +154,8 @@ lib/model/smart_info_response_dto.dart
lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_o_auth_dto.dart
lib/model/system_config_storage_template_dto.dart
lib/model/system_config_template_storage_option_dto.dart
lib/model/tag_response_dto.dart
lib/model/tag_type_enum.dart
lib/model/thumbnail_format.dart
@ -227,6 +231,8 @@ test/system_config_api_test.dart
test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart
test/system_config_o_auth_dto_test.dart
test/system_config_storage_template_dto_test.dart
test/system_config_template_storage_option_dto_test.dart
test/tag_api_test.dart
test/tag_response_dto_test.dart
test/tag_type_enum_test.dart

View File

@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.38.0
- API version: 1.38.2
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@ -113,6 +113,7 @@ Class | Method | HTTP request | Description
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config |
*SystemConfigApi* | [**getDefaults**](doc//SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults |
*SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options |
*SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config |
*TagApi* | [**create**](doc//TagApi.md#create) | **POST** /tag |
*TagApi* | [**delete**](doc//TagApi.md#delete) | **DELETE** /tag/{id} |
@ -186,6 +187,8 @@ Class | Method | HTTP request | Description
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
- [SystemConfigStorageTemplateDto](doc//SystemConfigStorageTemplateDto.md)
- [SystemConfigTemplateStorageOptionDto](doc//SystemConfigTemplateStorageOptionDto.md)
- [TagResponseDto](doc//TagResponseDto.md)
- [TagTypeEnum](doc//TagTypeEnum.md)
- [ThumbnailFormat](doc//ThumbnailFormat.md)

View File

@ -11,6 +11,7 @@ Method | HTTP request | Description
------------- | ------------- | -------------
[**getConfig**](SystemConfigApi.md#getconfig) | **GET** /system-config |
[**getDefaults**](SystemConfigApi.md#getdefaults) | **GET** /system-config/defaults |
[**getStorageTemplateOptions**](SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options |
[**updateConfig**](SystemConfigApi.md#updateconfig) | **PUT** /system-config |
@ -100,6 +101,49 @@ 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)
# **getStorageTemplateOptions**
> SystemConfigTemplateStorageOptionDto getStorageTemplateOptions()
### Example
```dart
import 'package:openapi/api.dart';
// TODO Configure HTTP Bearer authorization: bearer
// Case 1. Use String Token
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
// Case 2. Use Function which generate token.
// String yourTokenGeneratorFunction() { ... }
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SystemConfigApi();
try {
final result = api_instance.getStorageTemplateOptions();
print(result);
} catch (e) {
print('Exception when calling SystemConfigApi->getStorageTemplateOptions: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**SystemConfigTemplateStorageOptionDto**](SystemConfigTemplateStorageOptionDto.md)
### Authorization
[bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: application/json
[[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)
# **updateConfig**
> SystemConfigDto updateConfig(systemConfigDto)

View File

@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) | |
**oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) | |
**storageTemplate** | [**SystemConfigStorageTemplateDto**](SystemConfigStorageTemplateDto.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)

View File

@ -0,0 +1,15 @@
# openapi.model.SystemConfigStorageTemplateDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**template** | **String** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -0,0 +1,21 @@
# openapi.model.SystemConfigTemplateStorageOptionDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**yearOptions** | **List<String>** | | [default to const []]
**monthOptions** | **List<String>** | | [default to const []]
**dayOptions** | **List<String>** | | [default to const []]
**hourOptions** | **List<String>** | | [default to const []]
**minuteOptions** | **List<String>** | | [default to const []]
**secondOptions** | **List<String>** | | [default to const []]
**presetOptions** | **List<String>** | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@ -91,6 +91,8 @@ part 'model/smart_info_response_dto.dart';
part 'model/system_config_dto.dart';
part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_o_auth_dto.dart';
part 'model/system_config_storage_template_dto.dart';
part 'model/system_config_template_storage_option_dto.dart';
part 'model/tag_response_dto.dart';
part 'model/tag_type_enum.dart';
part 'model/thumbnail_format.dart';

View File

@ -98,6 +98,47 @@ class SystemConfigApi {
return null;
}
/// Performs an HTTP 'GET /system-config/storage-template-options' operation and returns the [Response].
Future<Response> getStorageTemplateOptionsWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/system-config/storage-template-options';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
Future<SystemConfigTemplateStorageOptionDto?> getStorageTemplateOptions() async {
final response = await getStorageTemplateOptionsWithHttpInfo();
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'SystemConfigTemplateStorageOptionDto',) as SystemConfigTemplateStorageOptionDto;
}
return null;
}
/// Performs an HTTP 'PUT /system-config' operation and returns the [Response].
/// Parameters:
///

View File

@ -298,6 +298,10 @@ class ApiClient {
return SystemConfigFFmpegDto.fromJson(value);
case 'SystemConfigOAuthDto':
return SystemConfigOAuthDto.fromJson(value);
case 'SystemConfigStorageTemplateDto':
return SystemConfigStorageTemplateDto.fromJson(value);
case 'SystemConfigTemplateStorageOptionDto':
return SystemConfigTemplateStorageOptionDto.fromJson(value);
case 'TagResponseDto':
return TagResponseDto.fromJson(value);
case 'TagTypeEnum':

View File

@ -15,30 +15,36 @@ class SystemConfigDto {
SystemConfigDto({
required this.ffmpeg,
required this.oauth,
required this.storageTemplate,
});
SystemConfigFFmpegDto ffmpeg;
SystemConfigOAuthDto oauth;
SystemConfigStorageTemplateDto storageTemplate;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto &&
other.ffmpeg == ffmpeg &&
other.oauth == oauth;
other.oauth == oauth &&
other.storageTemplate == storageTemplate;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(ffmpeg.hashCode) +
(oauth.hashCode);
(oauth.hashCode) +
(storageTemplate.hashCode);
@override
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, oauth=$oauth]';
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, oauth=$oauth, storageTemplate=$storageTemplate]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'ffmpeg'] = ffmpeg;
_json[r'oauth'] = oauth;
_json[r'storageTemplate'] = storageTemplate;
return _json;
}
@ -63,6 +69,7 @@ class SystemConfigDto {
return SystemConfigDto(
ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!,
oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!,
storageTemplate: SystemConfigStorageTemplateDto.fromJson(json[r'storageTemplate'])!,
);
}
return null;
@ -114,6 +121,7 @@ class SystemConfigDto {
static const requiredKeys = <String>{
'ffmpeg',
'oauth',
'storageTemplate',
};
}

View File

@ -0,0 +1,111 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SystemConfigStorageTemplateDto {
/// Returns a new [SystemConfigStorageTemplateDto] instance.
SystemConfigStorageTemplateDto({
required this.template,
});
String template;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigStorageTemplateDto &&
other.template == template;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(template.hashCode);
@override
String toString() => 'SystemConfigStorageTemplateDto[template=$template]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'template'] = template;
return _json;
}
/// Returns a new [SystemConfigStorageTemplateDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigStorageTemplateDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "SystemConfigStorageTemplateDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "SystemConfigStorageTemplateDto[$key]" has a null value in JSON.');
});
return true;
}());
return SystemConfigStorageTemplateDto(
template: mapValueOfType<String>(json, r'template')!,
);
}
return null;
}
static List<SystemConfigStorageTemplateDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigStorageTemplateDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigStorageTemplateDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigStorageTemplateDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigStorageTemplateDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigStorageTemplateDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigStorageTemplateDto-objects as value to a dart map
static Map<String, List<SystemConfigStorageTemplateDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigStorageTemplateDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigStorageTemplateDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'template',
};
}

View File

@ -0,0 +1,173 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
part of openapi.api;
class SystemConfigTemplateStorageOptionDto {
/// Returns a new [SystemConfigTemplateStorageOptionDto] instance.
SystemConfigTemplateStorageOptionDto({
this.yearOptions = const [],
this.monthOptions = const [],
this.dayOptions = const [],
this.hourOptions = const [],
this.minuteOptions = const [],
this.secondOptions = const [],
this.presetOptions = const [],
});
List<String> yearOptions;
List<String> monthOptions;
List<String> dayOptions;
List<String> hourOptions;
List<String> minuteOptions;
List<String> secondOptions;
List<String> presetOptions;
@override
bool operator ==(Object other) => identical(this, other) || other is SystemConfigTemplateStorageOptionDto &&
other.yearOptions == yearOptions &&
other.monthOptions == monthOptions &&
other.dayOptions == dayOptions &&
other.hourOptions == hourOptions &&
other.minuteOptions == minuteOptions &&
other.secondOptions == secondOptions &&
other.presetOptions == presetOptions;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(yearOptions.hashCode) +
(monthOptions.hashCode) +
(dayOptions.hashCode) +
(hourOptions.hashCode) +
(minuteOptions.hashCode) +
(secondOptions.hashCode) +
(presetOptions.hashCode);
@override
String toString() => 'SystemConfigTemplateStorageOptionDto[yearOptions=$yearOptions, monthOptions=$monthOptions, dayOptions=$dayOptions, hourOptions=$hourOptions, minuteOptions=$minuteOptions, secondOptions=$secondOptions, presetOptions=$presetOptions]';
Map<String, dynamic> toJson() {
final _json = <String, dynamic>{};
_json[r'yearOptions'] = yearOptions;
_json[r'monthOptions'] = monthOptions;
_json[r'dayOptions'] = dayOptions;
_json[r'hourOptions'] = hourOptions;
_json[r'minuteOptions'] = minuteOptions;
_json[r'secondOptions'] = secondOptions;
_json[r'presetOptions'] = presetOptions;
return _json;
}
/// Returns a new [SystemConfigTemplateStorageOptionDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static SystemConfigTemplateStorageOptionDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
// Ensure that the map contains the required keys.
// Note 1: the values aren't checked for validity beyond being non-null.
// Note 2: this code is stripped in release mode!
assert(() {
requiredKeys.forEach((key) {
assert(json.containsKey(key), 'Required key "SystemConfigTemplateStorageOptionDto[$key]" is missing from JSON.');
assert(json[key] != null, 'Required key "SystemConfigTemplateStorageOptionDto[$key]" has a null value in JSON.');
});
return true;
}());
return SystemConfigTemplateStorageOptionDto(
yearOptions: json[r'yearOptions'] is List
? (json[r'yearOptions'] as List).cast<String>()
: const [],
monthOptions: json[r'monthOptions'] is List
? (json[r'monthOptions'] as List).cast<String>()
: const [],
dayOptions: json[r'dayOptions'] is List
? (json[r'dayOptions'] as List).cast<String>()
: const [],
hourOptions: json[r'hourOptions'] is List
? (json[r'hourOptions'] as List).cast<String>()
: const [],
minuteOptions: json[r'minuteOptions'] is List
? (json[r'minuteOptions'] as List).cast<String>()
: const [],
secondOptions: json[r'secondOptions'] is List
? (json[r'secondOptions'] as List).cast<String>()
: const [],
presetOptions: json[r'presetOptions'] is List
? (json[r'presetOptions'] as List).cast<String>()
: const [],
);
}
return null;
}
static List<SystemConfigTemplateStorageOptionDto>? listFromJson(dynamic json, {bool growable = false,}) {
final result = <SystemConfigTemplateStorageOptionDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = SystemConfigTemplateStorageOptionDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, SystemConfigTemplateStorageOptionDto> mapFromJson(dynamic json) {
final map = <String, SystemConfigTemplateStorageOptionDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigTemplateStorageOptionDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of SystemConfigTemplateStorageOptionDto-objects as value to a dart map
static Map<String, List<SystemConfigTemplateStorageOptionDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<SystemConfigTemplateStorageOptionDto>>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = SystemConfigTemplateStorageOptionDto.listFromJson(entry.value, growable: growable,);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'yearOptions',
'monthOptions',
'dayOptions',
'hourOptions',
'minuteOptions',
'secondOptions',
'presetOptions',
};
}

View File

@ -27,6 +27,11 @@ void main() {
// TODO
});
//Future<SystemConfigTemplateStorageOptionDto> getStorageTemplateOptions() async
test('test getStorageTemplateOptions', () async {
// TODO
});
//Future<SystemConfigDto> updateConfig(SystemConfigDto systemConfigDto) async
test('test updateConfig', () async {
// TODO

View File

@ -26,6 +26,11 @@ void main() {
// TODO
});
// SystemConfigStorageTemplateDto storageTemplate
test('to test the property `storageTemplate`', () async {
// TODO
});
});

View File

@ -0,0 +1,27 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigStorageTemplateDto
void main() {
// final instance = SystemConfigStorageTemplateDto();
group('test SystemConfigStorageTemplateDto', () {
// String template
test('to test the property `template`', () async {
// TODO
});
});
}

View File

@ -0,0 +1,57 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.12
// ignore_for_file: unused_element, unused_import
// ignore_for_file: always_put_required_named_parameters_first
// ignore_for_file: constant_identifier_names
// ignore_for_file: lines_longer_than_80_chars
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for SystemConfigTemplateStorageOptionDto
void main() {
// final instance = SystemConfigTemplateStorageOptionDto();
group('test SystemConfigTemplateStorageOptionDto', () {
// List<String> yearOptions (default value: const [])
test('to test the property `yearOptions`', () async {
// TODO
});
// List<String> monthOptions (default value: const [])
test('to test the property `monthOptions`', () async {
// TODO
});
// List<String> dayOptions (default value: const [])
test('to test the property `dayOptions`', () async {
// TODO
});
// List<String> hourOptions (default value: const [])
test('to test the property `hourOptions`', () async {
// TODO
});
// List<String> minuteOptions (default value: const [])
test('to test the property `minuteOptions`', () async {
// TODO
});
// List<String> secondOptions (default value: const [])
test('to test the property `secondOptions`', () async {
// TODO
});
// List<String> presetOptions (default value: const [])
test('to test the property `presetOptions`', () async {
// TODO
});
});
}

10
notes.md Normal file
View File

@ -0,0 +1,10 @@
# User defined storage structure
# Folder structure
* Year is the top level
* Different parsing sequence will be the second level
# Filename
* Filename will always be appended by a unique ID. Maybe use https://github.com/ai/nanoid
* Example: `notes.md` -> `notes-1234567890.md`
* Filename will be unique in the same folder

View File

@ -13,6 +13,7 @@ import { DownloadModule } from '../../modules/download/download.module';
import { TagModule } from '../tag/tag.module';
import { AlbumModule } from '../album/album.module';
import { UserModule } from '../user/user.module';
import { StorageModule } from '@app/storage';
const ASSET_REPOSITORY_PROVIDER = {
provide: ASSET_REPOSITORY,
@ -28,6 +29,7 @@ const ASSET_REPOSITORY_PROVIDER = {
UserModule,
AlbumModule,
TagModule,
StorageModule,
forwardRef(() => AlbumModule),
BullModule.registerQueue({
name: QueueNameEnum.ASSET_UPLOADED,

View File

@ -11,7 +11,8 @@ import { DownloadService } from '../../modules/download/download.service';
import { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
import { Queue } from 'bull';
import { IAlbumRepository } from "../album/album-repository";
import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
describe('AssetService', () => {
let sui: AssetService;
@ -22,6 +23,7 @@ describe('AssetService', () => {
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
let storageSeriveMock: jest.Mocked<StorageService>;
const authUser: AuthUserDto = Object.freeze({
id: 'user_id_1',
email: 'auth@test.com',
@ -139,6 +141,7 @@ describe('AssetService', () => {
assetUploadedQueueMock,
videoConversionQueueMock,
downloadServiceMock as DownloadService,
storageSeriveMock,
);
});

View File

@ -55,6 +55,7 @@ import { Queue } from 'bull';
import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
const fileInfo = promisify(stat);
@ -79,6 +80,8 @@ export class AssetService {
private videoConversionQueue: Queue<IVideoTranscodeJob>,
private downloadService: DownloadService,
private storageService: StorageService,
) {}
public async handleUploadedAsset(
@ -113,6 +116,8 @@ export class AssetService {
throw new BadRequestException('Asset not created');
}
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
await this.videoConversionQueue.add(
mp4ConversionProcessorName,
{ asset: livePhotoAssetEntity },
@ -139,13 +144,15 @@ export class AssetService {
throw new BadRequestException('Asset not created');
}
const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname);
await this.assetUploadedQueue.add(
assetUploadedProcessorName,
{ asset: assetEntity, fileName: originalAssetData.originalname },
{ jobId: assetEntity.id },
{ asset: movedAsset, fileName: originalAssetData.originalname },
{ jobId: movedAsset.id },
);
return new AssetFileUploadResponseDto(assetEntity.id);
return new AssetFileUploadResponseDto(movedAsset.id);
} catch (err) {
await this.backgroundTaskService.deleteFileOnDisk([
{

View File

@ -0,0 +1,7 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class SystemConfigStorageTemplateDto {
@IsNotEmpty()
@IsString()
template!: string;
}

View File

@ -2,6 +2,7 @@ import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
export class SystemConfigDto {
@ValidateNested()
@ -9,6 +10,9 @@ export class SystemConfigDto {
@ValidateNested()
oauth!: SystemConfigOAuthDto;
@ValidateNested()
storageTemplate!: SystemConfigStorageTemplateDto;
}
export function mapConfig(config: SystemConfig): SystemConfigDto {

View File

@ -0,0 +1,9 @@
export class SystemConfigTemplateStorageOptionDto {
yearOptions!: string[];
monthOptions!: string[];
dayOptions!: string[];
hourOptions!: string[];
minuteOptions!: string[];
secondOptions!: string[];
presetOptions!: string[];
}

View File

@ -1,6 +1,7 @@
import { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../../decorators/authenticated.decorator';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
import { SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigService } from './system-config.service';
@ -25,4 +26,9 @@ export class SystemConfigController {
public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.systemConfigService.updateConfig(dto);
}
@Get('storage-template-options')
public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
return this.systemConfigService.getStorageTemplateOptions();
}
}

View File

@ -1,6 +1,16 @@
import {
supportedDayTokens,
supportedHourTokens,
supportedMinuteTokens,
supportedMonthTokens,
supportedPresetTokens,
supportedSecondTokens,
supportedYearTokens,
} from '@app/storage/constants/supported-datetime-template';
import { Injectable } from '@nestjs/common';
import { ImmichConfigService } from 'libs/immich-config/src';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
@Injectable()
export class SystemConfigService {
@ -17,7 +27,21 @@ export class SystemConfigService {
}
public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
await this.immichConfigService.updateConfig(dto);
return this.getConfig();
const config = await this.immichConfigService.updateConfig(dto);
return mapConfig(config);
}
public getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto {
const options = new SystemConfigTemplateStorageOptionDto();
options.dayOptions = supportedDayTokens;
options.monthOptions = supportedMonthTokens;
options.yearOptions = supportedYearTokens;
options.hourOptions = supportedHourTokens;
options.minuteOptions = supportedMinuteTokens;
options.secondOptions = supportedSecondTokens;
options.presetOptions = supportedPresetTokens;
return options;
}
}

View File

@ -19,7 +19,7 @@ describe('UserService', () => {
email: 'immich@test.com',
});
const adminUser: UserEntity = Object.freeze({
const adminUser: UserEntity = {
id: 'admin_id',
email: 'admin@test.com',
password: 'admin_password',
@ -32,9 +32,9 @@ describe('UserService', () => {
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
};
const immichUser: UserEntity = Object.freeze({
const immichUser: UserEntity = {
id: 'immich_id',
email: 'immich@test.com',
password: 'immich_password',
@ -47,9 +47,9 @@ describe('UserService', () => {
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
};
const updatedImmichUser: UserEntity = Object.freeze({
const updatedImmichUser: UserEntity = {
id: 'immich_id',
email: 'immich@test.com',
password: 'immich_password',
@ -62,7 +62,7 @@ describe('UserService', () => {
profileImagePath: '',
createdAt: '2021-01-01',
tags: [],
});
};
beforeAll(() => {
userRepositoryMock = newUserRepositoryMock();
@ -75,7 +75,7 @@ describe('UserService', () => {
});
describe('Update user', () => {
it('should update user', () => {
it('should update user', async () => {
const requestor = immichAuthUser;
const userToUpdate = immichUser;
@ -83,11 +83,11 @@ describe('UserService', () => {
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate));
userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser));
const result = sui.updateUser(requestor, {
const result = await sui.updateUser(requestor, {
id: userToUpdate.id,
shouldChangePassword: true,
});
expect(result).resolves.toBeDefined();
expect(result.shouldChangePassword).toEqual(true);
});
it('user can only update its information', () => {

View File

@ -44,6 +44,7 @@ export class ThumbnailGeneratorProcessor {
private configService: ConfigService,
) {
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
// TODO - Add observable paterrn to listen to the config change
}
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
@ -59,9 +60,7 @@ export class ThumbnailGeneratorProcessor {
mkdirSync(resizePath, { recursive: true });
}
const temp = asset.originalPath.split('/');
const originalFilename = temp[temp.length - 1].split('.')[0];
const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
if (asset.type == AssetType.IMAGE) {
try {

View File

@ -2169,12 +2169,38 @@
}
]
}
},
"/system-config/storage-template-options": {
"get": {
"operationId": "getStorageTemplateOptions",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/SystemConfigTemplateStorageOptionDto"
}
}
}
}
},
"tags": [
"System Config"
],
"security": [
{
"bearer": []
}
]
}
}
},
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.38.0",
"version": "1.38.2",
"contact": {}
},
"tags": [],
@ -3664,6 +3690,17 @@
"autoRegister"
]
},
"SystemConfigStorageTemplateDto": {
"type": "object",
"properties": {
"template": {
"type": "string"
}
},
"required": [
"template"
]
},
"SystemConfigDto": {
"type": "object",
"properties": {
@ -3672,11 +3709,71 @@
},
"oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto"
},
"storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
}
},
"required": [
"ffmpeg",
"oauth"
"oauth",
"storageTemplate"
]
},
"SystemConfigTemplateStorageOptionDto": {
"type": "object",
"properties": {
"yearOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"monthOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"dayOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"hourOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"minuteOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"secondOptions": {
"type": "array",
"items": {
"type": "string"
}
},
"presetOptions": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"yearOptions",
"monthOptions",
"dayOptions",
"hourOptions",
"minuteOptions",
"secondOptions",
"presetOptions"
]
}
}

View File

@ -25,6 +25,7 @@ export enum SystemConfigKey {
OAUTH_SCOPE = 'oauth.scope',
OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
STORAGE_TEMPLATE = 'storageTemplate.template',
}
export interface SystemConfig {
@ -44,4 +45,7 @@ export interface SystemConfig {
buttonText: string;
autoRegister: boolean;
};
storageTemplate: {
template: string;
};
}

View File

@ -1,11 +1,24 @@
import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
import { Module } from '@nestjs/common';
import { Module, Provider } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ImmichConfigService } from './immich-config.service';
export const INITIAL_SYSTEM_CONFIG = 'INITIAL_SYSTEM_CONFIG';
const providers: Provider[] = [
ImmichConfigService,
{
provide: INITIAL_SYSTEM_CONFIG,
inject: [ImmichConfigService],
useFactory: async (configService: ImmichConfigService) => {
return configService.getConfig();
},
},
];
@Module({
imports: [TypeOrmModule.forFeature([SystemConfigEntity])],
providers: [ImmichConfigService],
exports: [ImmichConfigService],
providers: [...providers],
exports: [...providers],
})
export class ImmichConfigModule {}

View File

@ -1,9 +1,12 @@
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity';
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial, In, Repository } from 'typeorm';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
const defaults: SystemConfig = Object.freeze({
ffmpeg: {
crf: '23',
@ -21,10 +24,19 @@ const defaults: SystemConfig = Object.freeze({
buttonText: 'Login with OAuth',
autoRegister: true,
},
storageTemplate: {
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
});
@Injectable()
export class ImmichConfigService {
private logger = new Logger(ImmichConfigService.name);
private validators: SystemConfigValidator[] = [];
public config$ = new Subject<SystemConfig>();
constructor(
@InjectRepository(SystemConfigEntity)
private systemConfigRepository: Repository<SystemConfigEntity>,
@ -34,6 +46,10 @@ export class ImmichConfigService {
return defaults;
}
public addValidator(validator: SystemConfigValidator) {
this.validators.push(validator);
}
public async getConfig() {
const overrides = await this.systemConfigRepository.find();
const config: DeepPartial<SystemConfig> = {};
@ -45,7 +61,16 @@ export class ImmichConfigService {
return _.defaultsDeep(config, defaults) as SystemConfig;
}
public async updateConfig(config: DeepPartial<SystemConfig> | null): Promise<void> {
public async updateConfig(config: SystemConfig): Promise<SystemConfig> {
try {
for (const validator of this.validators) {
await validator(config);
}
} catch (e) {
this.logger.warn(`Unable to save system config due to a validation error: ${e}`);
throw new BadRequestException(e instanceof Error ? e.message : e);
}
const updates: SystemConfigEntity[] = [];
const deletes: SystemConfigEntity[] = [];
@ -70,5 +95,11 @@ export class ImmichConfigService {
if (deletes.length > 0) {
await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) });
}
const newConfig = await this.getConfig();
this.config$.next(newConfig);
return newConfig;
}
}

View File

@ -0,0 +1,20 @@
export const supportedYearTokens = ['y', 'yy'];
export const supportedMonthTokens = ['M', 'MM', 'MMM', 'MMMM'];
export const supportedDayTokens = ['d', 'dd'];
export const supportedHourTokens = ['h', 'hh', 'H', 'HH'];
export const supportedMinuteTokens = ['m', 'mm'];
export const supportedSecondTokens = ['s', 'ss'];
export const supportedPresetTokens = [
'{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}-{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}-{{dd}}/{{filename}}',
'{{y}}/{{MM}}/{{filename}}',
'{{y}}/{{MMM}}/{{filename}}',
'{{y}}/{{MMMM}}/{{filename}}',
'{{y}}/{{MM}}/{{dd}}/{{filename}}',
'{{y}}/{{MMMM}}/{{dd}}/{{filename}}',
'{{y}}/{{y}}-{{MM}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}-{{MM}}-{{dd}}/{{filename}}',
'{{y}}-{{MMM}}-{{dd}}/{{filename}}',
'{{y}}-{{MMMM}}-{{dd}}/{{filename}}',
];

View File

@ -0,0 +1,2 @@
export * from './storage.module';
export * from './storage.service';

View File

@ -0,0 +1,6 @@
export interface IImmichStorage {
write(): Promise<void>;
read(): Promise<void>;
}
export enum IStorageType {}

View File

@ -0,0 +1,13 @@
import { AssetEntity } from '@app/database/entities/asset.entity';
import { SystemConfigEntity } from '@app/database/entities/system-config.entity';
import { ImmichConfigModule } from '@app/immich-config';
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { StorageService } from './storage.service';
@Module({
imports: [TypeOrmModule.forFeature([AssetEntity, SystemConfigEntity]), ImmichConfigModule],
providers: [StorageService],
exports: [StorageService],
})
export class StorageModule {}

View File

@ -0,0 +1,153 @@
import { APP_UPLOAD_LOCATION } from '@app/common';
import { AssetEntity } from '@app/database/entities/asset.entity';
import { SystemConfig } from '@app/database/entities/system-config.entity';
import { ImmichConfigService, INITIAL_SYSTEM_CONFIG } from '@app/immich-config';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import fsPromise from 'fs/promises';
import handlebar from 'handlebars';
import * as luxon from 'luxon';
import mv from 'mv';
import { constants } from 'node:fs';
import path from 'node:path';
import { promisify } from 'node:util';
import sanitize from 'sanitize-filename';
import { Repository } from 'typeorm';
import {
supportedDayTokens,
supportedHourTokens,
supportedMinuteTokens,
supportedMonthTokens,
supportedSecondTokens,
supportedYearTokens,
} from './constants/supported-datetime-template';
const moveFile = promisify<string, string, mv.Options>(mv);
@Injectable()
export class StorageService {
readonly log = new Logger(StorageService.name);
private storageTemplate: HandlebarsTemplateDelegate<any>;
constructor(
@InjectRepository(AssetEntity)
private assetRepository: Repository<AssetEntity>,
private immichConfigService: ImmichConfigService,
@Inject(INITIAL_SYSTEM_CONFIG) config: SystemConfig,
) {
this.storageTemplate = this.compile(config.storageTemplate.template);
this.immichConfigService.addValidator((config) => this.validateConfig(config));
this.immichConfigService.config$.subscribe((config) => {
this.log.debug(`Received new config, recompiling storage template: ${config.storageTemplate.template}`);
this.storageTemplate = this.compile(config.storageTemplate.template);
});
}
public async moveAsset(asset: AssetEntity, filename: string): Promise<AssetEntity> {
try {
const source = asset.originalPath;
const ext = path.extname(source).split('.').pop() as string;
const sanitized = sanitize(path.basename(filename, `.${ext}`));
const rootPath = path.join(APP_UPLOAD_LOCATION, asset.userId);
const storagePath = this.render(this.storageTemplate, asset, sanitized, ext);
const fullPath = path.normalize(path.join(rootPath, storagePath));
if (!fullPath.startsWith(rootPath)) {
this.log.warn(`Skipped attempt to access an invalid path: ${fullPath}. Path should start with ${rootPath}`);
return asset;
}
let duplicateCount = 0;
let destination = `${fullPath}.${ext}`;
while (true) {
const exists = await this.checkFileExist(destination);
if (!exists) {
break;
}
duplicateCount++;
destination = `${fullPath}_${duplicateCount}.${ext}`;
}
await this.safeMove(source, destination);
asset.originalPath = destination;
return await this.assetRepository.save(asset);
} catch (error: any) {
this.log.error(error, error.stack);
return asset;
}
}
private safeMove(source: string, destination: string): Promise<void> {
return moveFile(source, destination, { mkdirp: true, clobber: false });
}
private async checkFileExist(path: string): Promise<boolean> {
try {
await fsPromise.access(path, constants.F_OK);
return true;
} catch (_) {
return false;
}
}
private validateConfig(config: SystemConfig) {
this.validateStorageTemplate(config.storageTemplate.template);
}
private validateStorageTemplate(templateString: string) {
try {
const template = this.compile(templateString);
// test render an asset
this.render(
template,
{
createdAt: new Date().toISOString(),
originalPath: '/upload/test/IMG_123.jpg',
} as AssetEntity,
'IMG_123',
'jpg',
);
} catch (e) {
this.log.warn(`Storage template validation failed: ${e}`);
throw new Error(`Invalid storage template: ${e}`);
}
}
private compile(template: string) {
return handlebar.compile(template, {
knownHelpers: undefined,
strict: true,
});
}
private render(template: HandlebarsTemplateDelegate<any>, asset: AssetEntity, filename: string, ext: string) {
const substitutions: Record<string, string> = {
filename,
ext,
};
const dt = luxon.DateTime.fromISO(new Date(asset.createdAt).toISOString());
const dateTokens = [
...supportedYearTokens,
...supportedMonthTokens,
...supportedDayTokens,
...supportedHourTokens,
...supportedMinuteTokens,
...supportedSecondTokens,
];
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
}
return template(substitutions);
}
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"declaration": true,
"outDir": "../../dist/libs/storage"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
}

View File

@ -79,6 +79,15 @@
"compilerOptions": {
"tsConfigPath": "libs/immich-config/tsconfig.lib.json"
}
},
"storage": {
"type": "library",
"root": "libs/storage",
"entryFile": "index",
"sourceRoot": "libs/storage/src",
"compilerOptions": {
"tsConfigPath": "libs/storage/tsconfig.lib.json"
}
}
}
}
}

180
server/package-lock.json generated
View File

@ -36,11 +36,13 @@
"fdir": "^5.3.0",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"mv": "^2.1.1",
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
@ -76,6 +78,7 @@
"@types/jest": "27.0.2",
"@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7",
"@types/mv": "^2.1.2",
"@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2",
@ -2544,6 +2547,12 @@
"@types/express": "*"
}
},
"node_modules/@types/mv": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz",
"integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==",
"dev": true
},
"node_modules/@types/node": {
"version": "16.11.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
@ -6168,6 +6177,34 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
"integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
},
"node_modules/handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.0",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/handlebars/node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@ -8178,6 +8215,45 @@
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"node_modules/mv": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
"integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
"dependencies": {
"mkdirp": "~0.5.1",
"ncp": "~2.0.0",
"rimraf": "~2.4.0"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/mv/node_modules/glob": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
"dependencies": {
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "2 || 3",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
}
},
"node_modules/mv/node_modules/rimraf": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
"dependencies": {
"glob": "^6.0.1"
},
"bin": {
"rimraf": "bin.js"
}
},
"node_modules/mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -8204,6 +8280,14 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true
},
"node_modules/ncp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
"integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA==",
"bin": {
"ncp": "bin/ncp"
}
},
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -8215,8 +8299,7 @@
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node_modules/nest-commander": {
"version": "3.3.0",
@ -11006,6 +11089,18 @@
"node": ">=4.2.0"
}
},
"node_modules/uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/uid2": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@ -11329,6 +11424,11 @@
"node": ">=0.10.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -13393,6 +13493,12 @@
"@types/express": "*"
}
},
"@types/mv": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/@types/mv/-/mv-2.1.2.tgz",
"integrity": "sha512-IvAjPuiQ2exDicnTrMidt1m+tj3gZ60BM0PaoRsU0m9Cn+lrOyemuO9Tf8CvHFmXlxMjr1TVCfadi9sfwbSuKg==",
"dev": true
},
"@types/node": {
"version": "16.11.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz",
@ -16213,6 +16319,25 @@
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
"integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ=="
},
"handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
"requires": {
"minimist": "^1.2.5",
"neo-async": "^2.6.0",
"source-map": "^0.6.1",
"uglify-js": "^3.1.4",
"wordwrap": "^1.0.0"
},
"dependencies": {
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
}
}
},
"har-schema": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz",
@ -17773,6 +17898,38 @@
"resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"mv": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz",
"integrity": "sha512-at/ZndSy3xEGJ8i0ygALh8ru9qy7gWW1cmkaqBN29JmMlIvM//MEO9y1sk/avxuwnPcfhkejkLsuPxH81BrkSg==",
"requires": {
"mkdirp": "~0.5.1",
"ncp": "~2.0.0",
"rimraf": "~2.4.0"
},
"dependencies": {
"glob": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz",
"integrity": "sha512-MKZeRNyYZAVVVG1oZeLaWie1uweH40m9AZwIwxyPbTSX4hHrVYSzLg0Ro5Z5R7XKkIX+Cc6oD1rqeDJnwsB8/A==",
"requires": {
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "2 || 3",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
}
},
"rimraf": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz",
"integrity": "sha512-J5xnxTyqaiw06JjMftq7L9ouA448dw/E7dKghkP9WpKNuwmARNNg+Gk8/u5ryb9N/Yo2+z3MCwuqFK/+qPOPfQ==",
"requires": {
"glob": "^6.0.1"
}
}
}
},
"mz": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -17799,6 +17956,11 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true
},
"ncp": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz",
"integrity": "sha512-zIdGUrPRFTUELUvr3Gmc7KZ2Sw/h1PiVM0Af/oHB6zgnV1ikqSfRk+TOufi79aHYCW3NiOXmr1BP5nWbzojLaA=="
},
"negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -17807,8 +17969,7 @@
"neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"dev": true
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"nest-commander": {
"version": "3.3.0",
@ -19794,6 +19955,12 @@
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
"devOptional": true
},
"uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"optional": true
},
"uid2": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@ -20049,6 +20216,11 @@
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true
},
"wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -59,11 +59,13 @@
"fdir": "^5.3.0",
"fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21",
"luxon": "^3.0.3",
"mv": "^2.1.1",
"nest-commander": "^3.3.0",
"openid-client": "^5.2.1",
"passport": "^0.6.0",
@ -96,6 +98,7 @@
"@types/jest": "27.0.2",
"@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7",
"@types/mv": "^2.1.2",
"@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2",
@ -142,7 +145,8 @@
"@app/database/config": "<rootDir>/libs/database/src/config",
"@app/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1",
"^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1"
"^@app/immich-config(|/.*)$": "<rootDir>/libs/immich-config/src/$1",
"^@app/storage(|/.*)$": "<rootDir>/libs/storage/src/$1"
}
}
}

View File

@ -16,15 +16,41 @@
"esModuleInterop": true,
"baseUrl": "./",
"paths": {
"@app/common": ["libs/common/src"],
"@app/common/*": ["libs/common/src/*"],
"@app/database": ["libs/database/src"],
"@app/database/*": ["libs/database/src/*"],
"@app/job": ["libs/job/src"],
"@app/job/*": ["libs/job/src/*"],
"@app/immich-config": ["libs/immich-config/src"],
"@app/immich-config/*": ["libs/immich-config/src/*"]
"@app/common": [
"libs/common/src"
],
"@app/common/*": [
"libs/common/src/*"
],
"@app/database": [
"libs/database/src"
],
"@app/database/*": [
"libs/database/src/*"
],
"@app/job": [
"libs/job/src"
],
"@app/job/*": [
"libs/job/src/*"
],
"@app/immich-config": [
"libs/immich-config/src"
],
"@app/immich-config/*": [
"libs/immich-config/src/*"
],
"@app/storage": [
"libs/storage/src"
],
"@app/storage/*": [
"libs/storage/src/*"
]
}
},
"exclude": ["dist", "node_modules", "upload"]
}
"exclude": [
"dist",
"node_modules",
"upload"
]
}

106
web/package-lock.json generated
View File

@ -12,9 +12,11 @@
"cookie": "^0.4.2",
"copy-image-clipboard": "^2.1.2",
"exifr": "^7.1.3",
"handlebars": "^4.7.7",
"leaflet": "^1.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"luxon": "^3.1.1",
"socket.io-client": "^4.5.1",
"svelte-keydown": "^0.5.0",
"svelte-material-icons": "^2.0.2"
@ -34,6 +36,7 @@
"@types/leaflet": "^1.7.10",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.1.0",
"@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
@ -3319,6 +3322,12 @@
"@types/lodash": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz",
"integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==",
"dev": true
},
"node_modules/@types/node": {
"version": "18.11.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz",
@ -6149,6 +6158,26 @@
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
"dev": true
},
"node_modules/handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.0",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@ -8976,6 +9005,14 @@
"node": ">=10"
}
},
"node_modules/luxon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz",
"integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw==",
"engines": {
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@ -9114,7 +9151,6 @@
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
"dev": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@ -9178,6 +9214,11 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node_modules/node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -10280,7 +10321,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"engines": {
"node": ">=0.10.0"
}
@ -10835,6 +10875,18 @@
"node": ">=4.2.0"
}
},
"node_modules/uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/undici": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz",
@ -11163,6 +11215,11 @@
"node": ">=0.10.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -13726,6 +13783,12 @@
"@types/lodash": "*"
}
},
"@types/luxon": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.1.0.tgz",
"integrity": "sha512-gCd/HcCgjqSxfMrgtqxCgYk/22NBQfypwFUG7ZAyG/4pqs51WLTcUzVp1hqTbieDYeHS3WoVEh2Yv/2l+7B0Vg==",
"dev": true
},
"@types/node": {
"version": "18.11.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz",
@ -15703,6 +15766,18 @@
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
"dev": true
},
"handlebars": {
"version": "4.7.7",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz",
"integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==",
"requires": {
"minimist": "^1.2.5",
"neo-async": "^2.6.0",
"source-map": "^0.6.1",
"uglify-js": "^3.1.4",
"wordwrap": "^1.0.0"
}
},
"has": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@ -17789,6 +17864,11 @@
"yallist": "^4.0.0"
}
},
"luxon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.1.1.tgz",
"integrity": "sha512-Ah6DloGmvseB/pX1cAmjbFvyU/pKuwQMQqz7d0yvuDlVYLTs2WeDHQMpC8tGjm1da+BriHROW/OEIT/KfYg6xw=="
},
"lz-string": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@ -17887,8 +17967,7 @@
"minimist": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
"dev": true
"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="
},
"mkdirp": {
"version": "0.5.6",
@ -17934,6 +18013,11 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true
},
"neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
},
"node-int64": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -18708,8 +18792,7 @@
"source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
},
"source-map-js": {
"version": "1.0.2",
@ -19092,6 +19175,12 @@
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
"dev": true
},
"uglify-js": {
"version": "3.17.4",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz",
"integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==",
"optional": true
},
"undici": {
"version": "5.13.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz",
@ -19304,6 +19393,11 @@
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true
},
"wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="
},
"wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -32,6 +32,7 @@
"@types/leaflet": "^1.7.10",
"@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.1.0",
"@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0",
@ -62,9 +63,11 @@
"cookie": "^0.4.2",
"copy-image-clipboard": "^2.1.2",
"exifr": "^7.1.3",
"handlebars": "^4.7.7",
"leaflet": "^1.8.0",
"lodash": "^4.17.21",
"lodash-es": "^4.17.21",
"luxon": "^3.1.1",
"socket.io-client": "^4.5.1",
"svelte-keydown": "^0.5.0",
"svelte-material-icons": "^2.0.2"

View File

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.38.0
* The version of the OpenAPI document: 1.38.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@ -1443,6 +1443,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'oauth': SystemConfigOAuthDto;
/**
*
* @type {SystemConfigStorageTemplateDto}
* @memberof SystemConfigDto
*/
'storageTemplate': SystemConfigStorageTemplateDto;
}
/**
*
@ -1530,6 +1536,68 @@ export interface SystemConfigOAuthDto {
*/
'autoRegister': boolean;
}
/**
*
* @export
* @interface SystemConfigStorageTemplateDto
*/
export interface SystemConfigStorageTemplateDto {
/**
*
* @type {string}
* @memberof SystemConfigStorageTemplateDto
*/
'template': string;
}
/**
*
* @export
* @interface SystemConfigTemplateStorageOptionDto
*/
export interface SystemConfigTemplateStorageOptionDto {
/**
*
* @type {Array<string>}
* @memberof SystemConfigTemplateStorageOptionDto
*/
'yearOptions': Array<string>;
/**
*
* @type {Array<string>}
* @memberof SystemConfigTemplateStorageOptionDto
*/
'monthOptions': Array<string>;
/**
*
* @type {Array<string>}
* @memberof SystemConfigTemplateStorageOptionDto
*/
'dayOptions': Array<string>;
/**
*
* @type {Array<string>}
* @memberof SystemConfigTemplateStorageOptionDto
*/
'hourOptions': Array<string>;
/**
*
* @type {Array<string>}
* @memberof SystemConfigTemplateStorageOptionDto
*/
'minuteOptions': Array<string>;
/**
*
* @type {Array<string>}
* @memberof SystemConfigTemplateStorageOptionDto
*/
'secondOptions': Array<string>;
/**
*
* @type {Array<string>}
* @memberof SystemConfigTemplateStorageOptionDto
*/
'presetOptions': Array<string>;
}
/**
*
* @export
@ -5312,6 +5380,39 @@ export const SystemConfigApiAxiosParamCreator = function (configuration?: Config
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStorageTemplateOptions: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/system-config/storage-template-options`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -5388,6 +5489,15 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getDefaults(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getStorageTemplateOptions(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SystemConfigTemplateStorageOptionDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getStorageTemplateOptions(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {SystemConfigDto} systemConfigDto
@ -5424,6 +5534,14 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
getDefaults(options?: any): AxiosPromise<SystemConfigDto> {
return localVarFp.getDefaults(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getStorageTemplateOptions(options?: any): AxiosPromise<SystemConfigTemplateStorageOptionDto> {
return localVarFp.getStorageTemplateOptions(options).then((request) => request(axios, basePath));
},
/**
*
* @param {SystemConfigDto} systemConfigDto
@ -5463,6 +5581,16 @@ export class SystemConfigApi extends BaseAPI {
return SystemConfigApiFp(this.configuration).getDefaults(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof SystemConfigApi
*/
public getStorageTemplateOptions(options?: AxiosRequestConfig) {
return SystemConfigApiFp(this.configuration).getStorageTemplateOptions(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {SystemConfigDto} systemConfigDto

View File

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.38.0
* The version of the OpenAPI document: 1.38.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.38.0
* The version of the OpenAPI document: 1.38.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.38.0
* The version of the OpenAPI document: 1.38.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.38.0
* The version of the OpenAPI document: 1.38.2
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -59,11 +59,11 @@ input:focus-visible {
@layer utilities {
.immich-form-input {
@apply bg-slate-100 p-2 rounded-md focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-500 dark:disabled:bg-gray-900 disabled:cursor-not-allowed;
@apply bg-slate-200 p-2 rounded-lg focus:border-immich-primary text-sm dark:bg-gray-600 dark:text-immich-dark-fg disabled:bg-gray-400 dark:disabled:bg-gray-800 disabled:cursor-not-allowed disabled:text-gray-200;
}
.immich-form-label {
@apply font-medium text-sm text-gray-500 dark:text-gray-300;
@apply font-medium text-gray-500 dark:text-gray-300;
}
.immich-btn-primary {

View File

@ -25,12 +25,12 @@
const { data: configs } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
ffmpeg: ffmpegConfig,
oauth: configs.oauth
...configs,
ffmpeg: ffmpegConfig
});
ffmpegConfig = result.data.ffmpeg;
savedConfig = result.data.ffmpeg;
ffmpegConfig = { ...result.data.ffmpeg };
savedConfig = { ...result.data.ffmpeg };
notificationController.show({
message: 'FFmpeg settings saved',
@ -48,8 +48,8 @@
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
ffmpegConfig = resetConfig.ffmpeg;
savedConfig = resetConfig.ffmpeg;
ffmpegConfig = { ...resetConfig.ffmpeg };
savedConfig = { ...resetConfig.ffmpeg };
notificationController.show({
message: 'Reset FFmpeg settings to the recent saved settings',
@ -60,8 +60,8 @@
async function resetToDefault() {
const { data: configs } = await api.systemConfigApi.getDefaults();
ffmpegConfig = configs.ffmpeg;
defaultConfig = configs.ffmpeg;
ffmpegConfig = { ...configs.ffmpeg };
defaultConfig = { ...configs.ffmpeg };
notificationController.show({
message: 'Reset FFmpeg settings to default',
@ -74,52 +74,56 @@
{#await getConfigs() then}
<div in:fade={{ duration: 500 }}>
<form autocomplete="off" on:submit|preventDefault>
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="CRF"
bind:value={ffmpegConfig.crf}
required={true}
isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
/>
<div class="flex flex-col gap-4 ml-4 mt-4">
<SettingInputField
inputType={SettingInputFieldType.NUMBER}
label="CRF"
bind:value={ffmpegConfig.crf}
required={true}
isEdited={!(ffmpegConfig.crf == savedConfig.crf)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="PRESET"
bind:value={ffmpegConfig.preset}
required={true}
isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="PRESET"
bind:value={ffmpegConfig.preset}
required={true}
isEdited={!(ffmpegConfig.preset == savedConfig.preset)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="AUDIO CODEC"
bind:value={ffmpegConfig.targetAudioCodec}
required={true}
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="AUDIO CODEC"
bind:value={ffmpegConfig.targetAudioCodec}
required={true}
isEdited={!(ffmpegConfig.targetAudioCodec == savedConfig.targetAudioCodec)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="VIDEO CODEC"
bind:value={ffmpegConfig.targetVideoCodec}
required={true}
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="VIDEO CODEC"
bind:value={ffmpegConfig.targetVideoCodec}
required={true}
isEdited={!(ffmpegConfig.targetVideoCodec == savedConfig.targetVideoCodec)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCALING"
bind:value={ffmpegConfig.targetScaling}
required={true}
isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCALING"
bind:value={ffmpegConfig.targetScaling}
required={true}
isEdited={!(ffmpegConfig.targetScaling == savedConfig.targetScaling)}
/>
</div>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
/>
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}

View File

@ -25,8 +25,8 @@
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
oauthConfig = resetConfig.oauth;
savedConfig = resetConfig.oauth;
oauthConfig = { ...resetConfig.oauth };
savedConfig = { ...resetConfig.oauth };
notificationController.show({
message: 'Reset OAuth settings to the last saved settings',
@ -39,12 +39,12 @@
const { data: currentConfig } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
ffmpeg: currentConfig.ffmpeg,
...currentConfig,
oauth: oauthConfig
});
oauthConfig = result.data.oauth;
savedConfig = result.data.oauth;
oauthConfig = { ...result.data.oauth };
savedConfig = { ...result.data.oauth };
notificationController.show({
message: 'OAuth settings saved',
@ -62,7 +62,7 @@
async function resetToDefault() {
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
oauthConfig = defaultConfig.oauth;
oauthConfig = { ...defaultConfig.oauth };
notificationController.show({
message: 'Reset OAuth settings to default',
@ -80,51 +80,52 @@
</div>
<hr class="m-4" />
<div class="flex flex-col gap-4 ml-4">
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={oauthConfig.issuerUrl}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="ISSUER URL"
bind:value={oauthConfig.issuerUrl}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.issuerUrl == savedConfig.issuerUrl)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={oauthConfig.clientId}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT ID"
bind:value={oauthConfig.clientId}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientId == savedConfig.clientId)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={oauthConfig.clientSecret}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="CLIENT SECRET"
bind:value={oauthConfig.clientSecret}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.clientSecret == savedConfig.clientSecret)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={oauthConfig.scope}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.scope == savedConfig.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="SCOPE"
bind:value={oauthConfig.scope}
required={true}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.scope == savedConfig.scope)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={oauthConfig.buttonText}
required={false}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
/>
<SettingInputField
inputType={SettingInputFieldType.TEXT}
label="BUTTON TEXT"
bind:value={oauthConfig.buttonText}
required={false}
disabled={!oauthConfig.enabled}
isEdited={!(oauthConfig.buttonText == savedConfig.buttonText)}
/>
</div>
<div class="mt-4">
<SettingSwitch
@ -135,12 +136,14 @@
/>
</div>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
/>
<div class="ml-4">
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
/>
</div>
</form>
</div>
{/await}

View File

@ -6,11 +6,11 @@
export let showResetToDefault = true;
</script>
<div class="flex justify-between gap-2 mx-4 mt-8">
<div class="flex justify-between gap-2 mt-8">
<div class="left">
{#if showResetToDefault}
<button
on:click|preventDefault={() => dispatch('reset-to-default')}
on:click={() => dispatch('reset-to-default')}
class="text-sm dark:text-immich-dark-primary hover:dark:text-immich-dark-primary/75 text-immich-primary hover:text-immich-primary/75 font-medium bg-none"
>
Reset to default
@ -20,7 +20,7 @@
<div class="right">
<button
on:click|preventDefault={() => dispatch('reset')}
on:click={() => dispatch('reset')}
class="text-sm bg-gray-500 dark:bg-gray-200 hover:bg-gray-500/75 dark:hover:bg-gray-200/80 px-4 py-2 text-white dark:text-immich-dark-gray rounded-full shadow-md font-medium disabled:opacity-50 disabled:cursor-not-allowed"
>Reset
</button>

View File

@ -12,19 +12,19 @@
export let inputType: SettingInputFieldType;
export let value: string;
export let label: string;
export let label = '';
export let required = false;
export let disabled = false;
export let isEdited: boolean;
export let isEdited = false;
const handleInput = (e: Event) => {
value = (e.target as HTMLInputElement).value;
};
</script>
<div class="m-4 flex flex-col gap-2">
<div class="flex place-items-center gap-1">
<label class="immich-form-label" for={label}>{label.toUpperCase()} </label>
<div class="w-full">
<div class={`flex place-items-center gap-1 h-[26px]`}>
<label class={`immich-form-label text-xs`} for={label}>{label.toUpperCase()} </label>
{#if required}
<div class="text-red-400">*</div>
{/if}
@ -32,14 +32,14 @@
{#if isEdited}
<div
transition:fly={{ x: 10, duration: 200, easing: quintOut }}
class="text-gray-500 text-xs italic"
class="bg-orange-100 px-2 rounded-full text-orange-900 text-[10px]"
>
Unsaved change
</div>
{/if}
</div>
<input
class="immich-form-input"
class="immich-form-input w-full"
id={label}
name={label}
type={inputType}

View File

@ -7,7 +7,7 @@
<div class="flex justify-between mx-4 place-items-center">
<div>
<h2 class="immich-form-label">
<h2 class="immich-form-label text-sm">
{title.toUpperCase()}
</h2>

View File

@ -0,0 +1,227 @@
<script lang="ts">
import {
api,
SystemConfigStorageTemplateDto,
SystemConfigTemplateStorageOptionDto,
UserResponseDto
} from '@api';
import * as luxon from 'luxon';
import handlebar from 'handlebars';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { fade } from 'svelte/transition';
import SupportedDatetimePanel from './supported-datetime-panel.svelte';
import SupportedVariablesPanel from './supported-variables-panel.svelte';
import SettingButtonsRow from '../setting-buttons-row.svelte';
import _ from 'lodash';
import {
notificationController,
NotificationType
} from '$lib/components/shared-components/notification/notification';
import SettingInputField, { SettingInputFieldType } from '../setting-input-field.svelte';
export let storageConfig: SystemConfigStorageTemplateDto;
export let user: UserResponseDto;
let savedConfig: SystemConfigStorageTemplateDto;
let defaultConfig: SystemConfigStorageTemplateDto;
let templateOptions: SystemConfigTemplateStorageOptionDto;
let selectedPreset = '';
async function getConfigs() {
[savedConfig, defaultConfig, templateOptions] = await Promise.all([
api.systemConfigApi.getConfig().then((res) => res.data.storageTemplate),
api.systemConfigApi.getDefaults().then((res) => res.data.storageTemplate),
api.systemConfigApi.getStorageTemplateOptions().then((res) => res.data)
]);
selectedPreset = templateOptions.presetOptions[0];
}
const getSupportDateTimeFormat = async () => {
const { data } = await api.systemConfigApi.getStorageTemplateOptions();
return data;
};
$: parsedTemplate = () => {
try {
return renderTemplate(storageConfig.template);
} catch (error) {
return 'error';
}
};
const renderTemplate = (templateString: string) => {
const template = handlebar.compile(templateString, {
knownHelpers: undefined
});
const substitutions: Record<string, string> = {
filename: 'IMG_10041123',
ext: 'jpeg'
};
const dt = luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString());
const dateTokens = [
...templateOptions.yearOptions,
...templateOptions.monthOptions,
...templateOptions.dayOptions,
...templateOptions.hourOptions,
...templateOptions.minuteOptions,
...templateOptions.secondOptions
];
for (const token of dateTokens) {
substitutions[token] = dt.toFormat(token);
}
return template(substitutions);
};
async function reset() {
const { data: resetConfig } = await api.systemConfigApi.getConfig();
storageConfig.template = resetConfig.storageTemplate.template;
savedConfig.template = resetConfig.storageTemplate.template;
notificationController.show({
message: 'Reset storage template settings to the recent saved settings',
type: NotificationType.Info
});
}
async function saveSetting() {
try {
const { data: currentConfig } = await api.systemConfigApi.getConfig();
const result = await api.systemConfigApi.updateConfig({
...currentConfig,
storageTemplate: storageConfig
});
storageConfig.template = result.data.storageTemplate.template;
savedConfig.template = result.data.storageTemplate.template;
notificationController.show({
message: 'Storage template saved',
type: NotificationType.Info
});
} catch (e) {
console.error('Error [storage-template-settings] [saveSetting]', e);
notificationController.show({
message: 'Unable to save settings',
type: NotificationType.Error
});
}
}
async function resetToDefault() {
const { data: defaultConfig } = await api.systemConfigApi.getDefaults();
storageConfig.template = defaultConfig.storageTemplate.template;
notificationController.show({
message: 'Reset storage template to default',
type: NotificationType.Info
});
}
const handlePresetSelection = () => {
storageConfig.template = selectedPreset;
};
</script>
<section class="dark:text-immich-dark-fg">
{#await getConfigs() then}
<div id="directory-path-builder" class="m-4">
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
Variables
</h3>
<section class="support-date">
{#await getSupportDateTimeFormat()}
<LoadingSpinner />
{:then options}
<div transition:fade={{ duration: 200 }}>
<SupportedDatetimePanel {options} />
</div>
{/await}
</section>
<section class="support-date">
<SupportedVariablesPanel />
</section>
<div class="mt-4 flex flex-col">
<h3 class="font-medium text-immich-primary dark:text-immich-dark-primary text-base">
Template
</h3>
<div class="text-xs my-2">
<h4>PREVIEW</h4>
</div>
<p class="text-xs">
Approximately path length limit : <span
class="font-semibold text-immich-primary dark:text-immich-dark-primary"
>{parsedTemplate().length + user.id.length + 'UPLOAD_LOCATION'.length}</span
>/260
</p>
<p class="text-xs">
{user.id} is the user's ID
</p>
<p
class="text-xs p-4 bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg py-2 rounded-lg mt-2"
>
<span class="text-immich-fg/25 dark:text-immich-dark-fg/50"
>UPLOAD_LOCATION/{user.id}</span
>/{parsedTemplate()}.jpeg
</p>
<form autocomplete="off" class="flex flex-col" on:submit|preventDefault>
<div class="flex flex-col my-2">
<label class="text-xs" for="presets">PRESET</label>
<select
class="text-sm bg-slate-200 p-2 rounded-lg mt-2 dark:bg-gray-600 hover:cursor-pointer"
name="presets"
id="preset-select"
bind:value={selectedPreset}
on:change={handlePresetSelection}
>
{#each templateOptions.presetOptions as preset}
<option value={preset}>{renderTemplate(preset)}</option>
{/each}
</select>
</div>
<div class="flex gap-2 align-bottom">
<SettingInputField
label="template"
required
inputType={SettingInputFieldType.TEXT}
bind:value={storageConfig.template}
isEdited={!(storageConfig.template === savedConfig.template)}
/>
<div class="flex-0">
<SettingInputField
label="Extension"
inputType={SettingInputFieldType.TEXT}
value={'.jpeg'}
disabled
/>
</div>
</div>
<SettingButtonsRow
on:reset={reset}
on:save={saveSetting}
on:reset-to-default={resetToDefault}
showResetToDefault={!_.isEqual(savedConfig, defaultConfig)}
/>
</form>
</div>
</div>
{/await}
</section>

View File

@ -0,0 +1,78 @@
<script lang="ts">
import { SystemConfigTemplateStorageOptionDto } from '@api';
import * as luxon from 'luxon';
export let options: SystemConfigTemplateStorageOptionDto;
const getLuxonExample = (format: string) => {
return luxon.DateTime.fromISO(new Date('2022-09-04T20:03:05.250').toISOString()).toFormat(
format
);
};
</script>
<div class="text-xs mt-2">
<h4>DATE & TIME</h4>
</div>
<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
<div class="mb-2 text-gray-600 dark:text-immich-dark-fg">
<p>Asset's creation timestamp is used for the datetime information</p>
<p>Sample time 2022-09-04T20:03:05.250</p>
</div>
<div class="flex gap-[50px]">
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">YEAR</p>
<ul>
{#each options.yearOptions as yearFormat}
<li>{'{{'}{yearFormat}{'}}'} - {getLuxonExample(yearFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MONTH</p>
<ul>
{#each options.monthOptions as monthFormat}
<li>{'{{'}{monthFormat}{'}}'} - {getLuxonExample(monthFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">DAY</p>
<ul>
{#each options.dayOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">HOUR</p>
<ul>
{#each options.hourOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">MINUTE</p>
<ul>
{#each options.minuteOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">SECOND</p>
<ul>
{#each options.secondOptions as dayFormat}
<li>{'{{'}{dayFormat}{'}}'} - {getLuxonExample(dayFormat)}</li>
{/each}
</ul>
</div>
</div>
</div>

View File

@ -0,0 +1,21 @@
<div class="text-xs mt-4">
<h4>OTHER VARIABLES</h4>
</div>
<div class="text-xs bg-gray-200 dark:bg-gray-700 dark:text-immich-dark-fg p-4 mt-2 rounded-lg">
<div class="flex gap-[50px]">
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE NAME</p>
<ul>
<li>{`{{filename}}`}</li>
</ul>
</div>
<div>
<p class="text-immich-primary font-medium dark:text-immich-dark-primary">FILE EXTENSION</p>
<ul>
<li>{`{{ext}}`}</li>
</ul>
</div>
</div>
</div>

View File

@ -2,11 +2,13 @@
import FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-settings.svelte';
import SettingAccordion from '$lib/components/admin-page/settings/setting-accordion.svelte';
import StorageTemplateSettings from '$lib/components/admin-page/settings/storate-template/storage-template-settings.svelte';
import LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { api, SystemConfigDto } from '@api';
import type { PageData } from './$types';
let systemConfig: SystemConfigDto;
export let data: PageData;
const getConfig = async () => {
const { data } = await api.systemConfigApi.getConfig();
systemConfig = data;
@ -33,5 +35,12 @@
<SettingAccordion title="OAuth Settings" subtitle="Manage the OAuth integration to Immich app">
<OAuthSettings oauthConfig={configs.oauth} />
</SettingAccordion>
<SettingAccordion
title="Storage Template"
subtitle="Manage the folder structure and file name of the upload asset"
>
<StorageTemplateSettings storageConfig={configs.storageTemplate} user={data.user} />
</SettingAccordion>
{/await}
</section>

View File

@ -22,7 +22,6 @@
onMount(() => {
allUsers = $page.data.allUsers;
console.log('getting all users', allUsers);
});
const isDeleted = (user: UserResponseDto): boolean => {