1
0
mirror of https://github.com/immich-app/immich.git synced 2024-11-24 08:52:28 +02:00

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 1371 additions and 169 deletions

View File

@ -64,6 +64,8 @@ doc/SystemConfigApi.md
doc/SystemConfigDto.md doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md doc/SystemConfigFFmpegDto.md
doc/SystemConfigOAuthDto.md doc/SystemConfigOAuthDto.md
doc/SystemConfigStorageTemplateDto.md
doc/SystemConfigTemplateStorageOptionDto.md
doc/TagApi.md doc/TagApi.md
doc/TagResponseDto.md doc/TagResponseDto.md
doc/TagTypeEnum.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_dto.dart
lib/model/system_config_f_fmpeg_dto.dart lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_o_auth_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_response_dto.dart
lib/model/tag_type_enum.dart lib/model/tag_type_enum.dart
lib/model/thumbnail_format.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_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart test/system_config_f_fmpeg_dto_test.dart
test/system_config_o_auth_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_api_test.dart
test/tag_response_dto_test.dart test/tag_response_dto_test.dart
test/tag_type_enum_test.dart test/tag_type_enum_test.dart

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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 { TagModule } from '../tag/tag.module';
import { AlbumModule } from '../album/album.module'; import { AlbumModule } from '../album/album.module';
import { UserModule } from '../user/user.module'; import { UserModule } from '../user/user.module';
import { StorageModule } from '@app/storage';
const ASSET_REPOSITORY_PROVIDER = { const ASSET_REPOSITORY_PROVIDER = {
provide: ASSET_REPOSITORY, provide: ASSET_REPOSITORY,
@ -28,6 +29,7 @@ const ASSET_REPOSITORY_PROVIDER = {
UserModule, UserModule,
AlbumModule, AlbumModule,
TagModule, TagModule,
StorageModule,
forwardRef(() => AlbumModule), forwardRef(() => AlbumModule),
BullModule.registerQueue({ BullModule.registerQueue({
name: QueueNameEnum.ASSET_UPLOADED, 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 { BackgroundTaskService } from '../../modules/background-task/background-task.service';
import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job'; import { IAssetUploadedJob, IVideoTranscodeJob } from '@app/job';
import { Queue } from 'bull'; import { Queue } from 'bull';
import { IAlbumRepository } from "../album/album-repository"; import { IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
describe('AssetService', () => { describe('AssetService', () => {
let sui: AssetService; let sui: AssetService;
@ -22,6 +23,7 @@ describe('AssetService', () => {
let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>; let backgroundTaskServiceMock: jest.Mocked<BackgroundTaskService>;
let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>; let assetUploadedQueueMock: jest.Mocked<Queue<IAssetUploadedJob>>;
let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>; let videoConversionQueueMock: jest.Mocked<Queue<IVideoTranscodeJob>>;
let storageSeriveMock: jest.Mocked<StorageService>;
const authUser: AuthUserDto = Object.freeze({ const authUser: AuthUserDto = Object.freeze({
id: 'user_id_1', id: 'user_id_1',
email: 'auth@test.com', email: 'auth@test.com',
@ -139,6 +141,7 @@ describe('AssetService', () => {
assetUploadedQueueMock, assetUploadedQueueMock,
videoConversionQueueMock, videoConversionQueueMock,
downloadServiceMock as DownloadService, downloadServiceMock as DownloadService,
storageSeriveMock,
); );
}); });

View File

@ -55,6 +55,7 @@ import { Queue } from 'bull';
import { DownloadService } from '../../modules/download/download.service'; import { DownloadService } from '../../modules/download/download.service';
import { DownloadDto } from './dto/download-library.dto'; import { DownloadDto } from './dto/download-library.dto';
import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository'; import { ALBUM_REPOSITORY, IAlbumRepository } from '../album/album-repository';
import { StorageService } from '@app/storage';
const fileInfo = promisify(stat); const fileInfo = promisify(stat);
@ -79,6 +80,8 @@ export class AssetService {
private videoConversionQueue: Queue<IVideoTranscodeJob>, private videoConversionQueue: Queue<IVideoTranscodeJob>,
private downloadService: DownloadService, private downloadService: DownloadService,
private storageService: StorageService,
) {} ) {}
public async handleUploadedAsset( public async handleUploadedAsset(
@ -113,6 +116,8 @@ export class AssetService {
throw new BadRequestException('Asset not created'); throw new BadRequestException('Asset not created');
} }
await this.storageService.moveAsset(livePhotoAssetEntity, originalAssetData.originalname);
await this.videoConversionQueue.add( await this.videoConversionQueue.add(
mp4ConversionProcessorName, mp4ConversionProcessorName,
{ asset: livePhotoAssetEntity }, { asset: livePhotoAssetEntity },
@ -139,13 +144,15 @@ export class AssetService {
throw new BadRequestException('Asset not created'); throw new BadRequestException('Asset not created');
} }
const movedAsset = await this.storageService.moveAsset(assetEntity, originalAssetData.originalname);
await this.assetUploadedQueue.add( await this.assetUploadedQueue.add(
assetUploadedProcessorName, assetUploadedProcessorName,
{ asset: assetEntity, fileName: originalAssetData.originalname }, { asset: movedAsset, fileName: originalAssetData.originalname },
{ jobId: assetEntity.id }, { jobId: movedAsset.id },
); );
return new AssetFileUploadResponseDto(assetEntity.id); return new AssetFileUploadResponseDto(movedAsset.id);
} catch (err) { } catch (err) {
await this.backgroundTaskService.deleteFileOnDisk([ 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 { ValidateNested } from 'class-validator';
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto'; import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
import { SystemConfigOAuthDto } from './system-config-oauth.dto'; import { SystemConfigOAuthDto } from './system-config-oauth.dto';
import { SystemConfigStorageTemplateDto } from './system-config-storage-template.dto';
export class SystemConfigDto { export class SystemConfigDto {
@ValidateNested() @ValidateNested()
@ -9,6 +10,9 @@ export class SystemConfigDto {
@ValidateNested() @ValidateNested()
oauth!: SystemConfigOAuthDto; oauth!: SystemConfigOAuthDto;
@ValidateNested()
storageTemplate!: SystemConfigStorageTemplateDto;
} }
export function mapConfig(config: SystemConfig): SystemConfigDto { 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 { Body, Controller, Get, Put, ValidationPipe } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Authenticated } from '../../decorators/authenticated.decorator'; 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 { SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigService } from './system-config.service'; import { SystemConfigService } from './system-config.service';
@ -25,4 +26,9 @@ export class SystemConfigController {
public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise<SystemConfigDto> { public updateConfig(@Body(ValidationPipe) dto: SystemConfigDto): Promise<SystemConfigDto> {
return this.systemConfigService.updateConfig(dto); 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 { Injectable } from '@nestjs/common';
import { ImmichConfigService } from 'libs/immich-config/src'; import { ImmichConfigService } from 'libs/immich-config/src';
import { mapConfig, SystemConfigDto } from './dto/system-config.dto'; import { mapConfig, SystemConfigDto } from './dto/system-config.dto';
import { SystemConfigTemplateStorageOptionDto } from './response-dto/system-config-template-storage-option.dto';
@Injectable() @Injectable()
export class SystemConfigService { export class SystemConfigService {
@ -17,7 +27,21 @@ export class SystemConfigService {
} }
public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> { public async updateConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
await this.immichConfigService.updateConfig(dto); const config = await this.immichConfigService.updateConfig(dto);
return this.getConfig(); 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', email: 'immich@test.com',
}); });
const adminUser: UserEntity = Object.freeze({ const adminUser: UserEntity = {
id: 'admin_id', id: 'admin_id',
email: 'admin@test.com', email: 'admin@test.com',
password: 'admin_password', password: 'admin_password',
@ -32,9 +32,9 @@ describe('UserService', () => {
profileImagePath: '', profileImagePath: '',
createdAt: '2021-01-01', createdAt: '2021-01-01',
tags: [], tags: [],
}); };
const immichUser: UserEntity = Object.freeze({ const immichUser: UserEntity = {
id: 'immich_id', id: 'immich_id',
email: 'immich@test.com', email: 'immich@test.com',
password: 'immich_password', password: 'immich_password',
@ -47,9 +47,9 @@ describe('UserService', () => {
profileImagePath: '', profileImagePath: '',
createdAt: '2021-01-01', createdAt: '2021-01-01',
tags: [], tags: [],
}); };
const updatedImmichUser: UserEntity = Object.freeze({ const updatedImmichUser: UserEntity = {
id: 'immich_id', id: 'immich_id',
email: 'immich@test.com', email: 'immich@test.com',
password: 'immich_password', password: 'immich_password',
@ -62,7 +62,7 @@ describe('UserService', () => {
profileImagePath: '', profileImagePath: '',
createdAt: '2021-01-01', createdAt: '2021-01-01',
tags: [], tags: [],
}); };
beforeAll(() => { beforeAll(() => {
userRepositoryMock = newUserRepositoryMock(); userRepositoryMock = newUserRepositoryMock();
@ -75,7 +75,7 @@ describe('UserService', () => {
}); });
describe('Update user', () => { describe('Update user', () => {
it('should update user', () => { it('should update user', async () => {
const requestor = immichAuthUser; const requestor = immichAuthUser;
const userToUpdate = immichUser; const userToUpdate = immichUser;
@ -83,11 +83,11 @@ describe('UserService', () => {
userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate)); userRepositoryMock.get.mockImplementationOnce(() => Promise.resolve(userToUpdate));
userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser)); userRepositoryMock.update.mockImplementationOnce(() => Promise.resolve(updatedImmichUser));
const result = sui.updateUser(requestor, { const result = await sui.updateUser(requestor, {
id: userToUpdate.id, id: userToUpdate.id,
shouldChangePassword: true, shouldChangePassword: true,
}); });
expect(result).resolves.toBeDefined(); expect(result.shouldChangePassword).toEqual(true);
}); });
it('user can only update its information', () => { it('user can only update its information', () => {

View File

@ -44,6 +44,7 @@ export class ThumbnailGeneratorProcessor {
private configService: ConfigService, private configService: ConfigService,
) { ) {
this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE; this.logLevel = this.configService.get('LOG_LEVEL') || ImmichLogLevel.SIMPLE;
// TODO - Add observable paterrn to listen to the config change
} }
@Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 }) @Process({ name: generateJPEGThumbnailProcessorName, concurrency: 3 })
@ -59,9 +60,7 @@ export class ThumbnailGeneratorProcessor {
mkdirSync(resizePath, { recursive: true }); mkdirSync(resizePath, { recursive: true });
} }
const temp = asset.originalPath.split('/'); const jpegThumbnailPath = join(resizePath, `${asset.id}.jpeg`);
const originalFilename = temp[temp.length - 1].split('.')[0];
const jpegThumbnailPath = join(resizePath, `${originalFilename}.jpeg`);
if (asset.type == AssetType.IMAGE) { if (asset.type == AssetType.IMAGE) {
try { 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": { "info": {
"title": "Immich", "title": "Immich",
"description": "Immich API", "description": "Immich API",
"version": "1.38.0", "version": "1.38.2",
"contact": {} "contact": {}
}, },
"tags": [], "tags": [],
@ -3664,6 +3690,17 @@
"autoRegister" "autoRegister"
] ]
}, },
"SystemConfigStorageTemplateDto": {
"type": "object",
"properties": {
"template": {
"type": "string"
}
},
"required": [
"template"
]
},
"SystemConfigDto": { "SystemConfigDto": {
"type": "object", "type": "object",
"properties": { "properties": {
@ -3672,11 +3709,71 @@
}, },
"oauth": { "oauth": {
"$ref": "#/components/schemas/SystemConfigOAuthDto" "$ref": "#/components/schemas/SystemConfigOAuthDto"
},
"storageTemplate": {
"$ref": "#/components/schemas/SystemConfigStorageTemplateDto"
} }
}, },
"required": [ "required": [
"ffmpeg", "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_SCOPE = 'oauth.scope',
OAUTH_BUTTON_TEXT = 'oauth.buttonText', OAUTH_BUTTON_TEXT = 'oauth.buttonText',
OAUTH_AUTO_REGISTER = 'oauth.autoRegister', OAUTH_AUTO_REGISTER = 'oauth.autoRegister',
STORAGE_TEMPLATE = 'storageTemplate.template',
} }
export interface SystemConfig { export interface SystemConfig {
@ -44,4 +45,7 @@ export interface SystemConfig {
buttonText: string; buttonText: string;
autoRegister: boolean; autoRegister: boolean;
}; };
storageTemplate: {
template: string;
};
} }

View File

@ -1,11 +1,24 @@
import { SystemConfigEntity } from '@app/database/entities/system-config.entity'; 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 { TypeOrmModule } from '@nestjs/typeorm';
import { ImmichConfigService } from './immich-config.service'; 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({ @Module({
imports: [TypeOrmModule.forFeature([SystemConfigEntity])], imports: [TypeOrmModule.forFeature([SystemConfigEntity])],
providers: [ImmichConfigService], providers: [...providers],
exports: [ImmichConfigService], exports: [...providers],
}) })
export class ImmichConfigModule {} export class ImmichConfigModule {}

View File

@ -1,9 +1,12 @@
import { SystemConfig, SystemConfigEntity, SystemConfigKey } from '@app/database/entities/system-config.entity'; 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 { InjectRepository } from '@nestjs/typeorm';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { Subject } from 'rxjs';
import { DeepPartial, In, Repository } from 'typeorm'; import { DeepPartial, In, Repository } from 'typeorm';
export type SystemConfigValidator = (config: SystemConfig) => void | Promise<void>;
const defaults: SystemConfig = Object.freeze({ const defaults: SystemConfig = Object.freeze({
ffmpeg: { ffmpeg: {
crf: '23', crf: '23',
@ -21,10 +24,19 @@ const defaults: SystemConfig = Object.freeze({
buttonText: 'Login with OAuth', buttonText: 'Login with OAuth',
autoRegister: true, autoRegister: true,
}, },
storageTemplate: {
template: '{{y}}/{{y}}-{{MM}}-{{dd}}/{{filename}}',
},
}); });
@Injectable() @Injectable()
export class ImmichConfigService { export class ImmichConfigService {
private logger = new Logger(ImmichConfigService.name);
private validators: SystemConfigValidator[] = [];
public config$ = new Subject<SystemConfig>();
constructor( constructor(
@InjectRepository(SystemConfigEntity) @InjectRepository(SystemConfigEntity)
private systemConfigRepository: Repository<SystemConfigEntity>, private systemConfigRepository: Repository<SystemConfigEntity>,
@ -34,6 +46,10 @@ export class ImmichConfigService {
return defaults; return defaults;
} }
public addValidator(validator: SystemConfigValidator) {
this.validators.push(validator);
}
public async getConfig() { public async getConfig() {
const overrides = await this.systemConfigRepository.find(); const overrides = await this.systemConfigRepository.find();
const config: DeepPartial<SystemConfig> = {}; const config: DeepPartial<SystemConfig> = {};
@ -45,7 +61,16 @@ export class ImmichConfigService {
return _.defaultsDeep(config, defaults) as SystemConfig; 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 updates: SystemConfigEntity[] = [];
const deletes: SystemConfigEntity[] = []; const deletes: SystemConfigEntity[] = [];
@ -70,5 +95,11 @@ export class ImmichConfigService {
if (deletes.length > 0) { if (deletes.length > 0) {
await this.systemConfigRepository.delete({ key: In(deletes.map((item) => item.key)) }); 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": { "compilerOptions": {
"tsConfigPath": "libs/immich-config/tsconfig.lib.json" "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", "fdir": "^5.3.0",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2", "geo-tz": "^7.0.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0", "i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0", "joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.5", "local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.0.3", "luxon": "^3.0.3",
"mv": "^2.1.1",
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"openid-client": "^5.2.1", "openid-client": "^5.2.1",
"passport": "^0.6.0", "passport": "^0.6.0",
@ -76,6 +78,7 @@
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/lodash": "^4.14.178", "@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/mv": "^2.1.2",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2", "@types/sharp": "^0.30.2",
@ -2544,6 +2547,12 @@
"@types/express": "*" "@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": { "node_modules/@types/node": {
"version": "16.11.21", "version": "16.11.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz", "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", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
"integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==" "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": { "node_modules/har-schema": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" "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": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -8204,6 +8280,14 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -8215,8 +8299,7 @@
"node_modules/neo-async": { "node_modules/neo-async": {
"version": "2.6.2", "version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
"dev": true
}, },
"node_modules/nest-commander": { "node_modules/nest-commander": {
"version": "3.3.0", "version": "3.3.0",
@ -11006,6 +11089,18 @@
"node": ">=4.2.0" "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": { "node_modules/uid2": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@ -11329,6 +11424,11 @@
"node": ">=0.10.0" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -13393,6 +13493,12 @@
"@types/express": "*" "@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": { "@types/node": {
"version": "16.11.21", "version": "16.11.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.21.tgz", "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", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.9.tgz",
"integrity": "sha512-NtNxqUcXgpW2iMrfqSfR73Glt39K+BLwWsPs94yR63v45T0Wbej7eRmL5cWfwEgqXnmjQp3zaJTshdRW/qC2ZQ==" "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": { "har-schema": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", "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", "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.8.tgz",
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" "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": { "mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@ -17799,6 +17956,11 @@
"integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=",
"dev": true "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": { "negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@ -17807,8 +17969,7 @@
"neo-async": { "neo-async": {
"version": "2.6.2", "version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="
"dev": true
}, },
"nest-commander": { "nest-commander": {
"version": "3.3.0", "version": "3.3.0",
@ -19794,6 +19955,12 @@
"integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==", "integrity": "sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ==",
"devOptional": true "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": { "uid2": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz", "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz",
@ -20049,6 +20216,11 @@
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true "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": { "wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@ -59,11 +59,13 @@
"fdir": "^5.3.0", "fdir": "^5.3.0",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"geo-tz": "^7.0.2", "geo-tz": "^7.0.2",
"handlebars": "^4.7.7",
"i18n-iso-countries": "^7.5.0", "i18n-iso-countries": "^7.5.0",
"joi": "^17.5.0", "joi": "^17.5.0",
"local-reverse-geocoder": "^0.12.5", "local-reverse-geocoder": "^0.12.5",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"luxon": "^3.0.3", "luxon": "^3.0.3",
"mv": "^2.1.1",
"nest-commander": "^3.3.0", "nest-commander": "^3.3.0",
"openid-client": "^5.2.1", "openid-client": "^5.2.1",
"passport": "^0.6.0", "passport": "^0.6.0",
@ -96,6 +98,7 @@
"@types/jest": "27.0.2", "@types/jest": "27.0.2",
"@types/lodash": "^4.14.178", "@types/lodash": "^4.14.178",
"@types/multer": "^1.4.7", "@types/multer": "^1.4.7",
"@types/mv": "^2.1.2",
"@types/node": "^16.0.0", "@types/node": "^16.0.0",
"@types/passport-jwt": "^3.0.6", "@types/passport-jwt": "^3.0.6",
"@types/sharp": "^0.30.2", "@types/sharp": "^0.30.2",
@ -142,7 +145,8 @@
"@app/database/config": "<rootDir>/libs/database/src/config", "@app/database/config": "<rootDir>/libs/database/src/config",
"@app/common": "<rootDir>/libs/common/src", "@app/common": "<rootDir>/libs/common/src",
"^@app/job(|/.*)$": "<rootDir>/libs/job/src/$1", "^@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, "esModuleInterop": true,
"baseUrl": "./", "baseUrl": "./",
"paths": { "paths": {
"@app/common": ["libs/common/src"], "@app/common": [
"@app/common/*": ["libs/common/src/*"], "libs/common/src"
"@app/database": ["libs/database/src"], ],
"@app/database/*": ["libs/database/src/*"], "@app/common/*": [
"@app/job": ["libs/job/src"], "libs/common/src/*"
"@app/job/*": ["libs/job/src/*"], ],
"@app/immich-config": ["libs/immich-config/src"], "@app/database": [
"@app/immich-config/*": ["libs/immich-config/src/*"] "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", "cookie": "^0.4.2",
"copy-image-clipboard": "^2.1.2", "copy-image-clipboard": "^2.1.2",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"handlebars": "^4.7.7",
"leaflet": "^1.8.0", "leaflet": "^1.8.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.1.1",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"svelte-keydown": "^0.5.0", "svelte-keydown": "^0.5.0",
"svelte-material-icons": "^2.0.2" "svelte-material-icons": "^2.0.2"
@ -34,6 +36,7 @@
"@types/leaflet": "^1.7.10", "@types/leaflet": "^1.7.10",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.1.0",
"@types/socket.io-client": "^3.0.0", "@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0", "@typescript-eslint/parser": "^5.27.0",
@ -3319,6 +3322,12 @@
"@types/lodash": "*" "@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": { "node_modules/@types/node": {
"version": "18.11.11", "version": "18.11.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz",
@ -6149,6 +6158,26 @@
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
"dev": true "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": { "node_modules/has": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@ -8976,6 +9005,14 @@
"node": ">=10" "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": { "node_modules/lz-string": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@ -9114,7 +9151,6 @@
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==",
"dev": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
@ -9178,6 +9214,11 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true "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": { "node_modules/node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -10280,7 +10321,6 @@
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@ -10835,6 +10875,18 @@
"node": ">=4.2.0" "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": { "node_modules/undici": {
"version": "5.13.0", "version": "5.13.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz",
@ -11163,6 +11215,11 @@
"node": ">=0.10.0" "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": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
@ -13726,6 +13783,12 @@
"@types/lodash": "*" "@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": { "@types/node": {
"version": "18.11.11", "version": "18.11.11",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.11.11.tgz",
@ -15703,6 +15766,18 @@
"integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==", "integrity": "sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==",
"dev": true "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": { "has": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
@ -17789,6 +17864,11 @@
"yallist": "^4.0.0" "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": { "lz-string": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.4.4.tgz",
@ -17887,8 +17967,7 @@
"minimist": { "minimist": {
"version": "1.2.7", "version": "1.2.7",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.7.tgz",
"integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g==", "integrity": "sha512-bzfL1YUZsP41gmu/qjrEk0Q6i2ix/cVeAhbCbqH9u3zYutS1cLg00qhrD0M2MVdCcx4Sc0UpP2eBWo9rotpq6g=="
"dev": true
}, },
"mkdirp": { "mkdirp": {
"version": "0.5.6", "version": "0.5.6",
@ -17934,6 +18013,11 @@
"integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==",
"dev": true "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": { "node-int64": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -18708,8 +18792,7 @@
"source-map": { "source-map": {
"version": "0.6.1", "version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="
"dev": true
}, },
"source-map-js": { "source-map-js": {
"version": "1.0.2", "version": "1.0.2",
@ -19092,6 +19175,12 @@
"integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==", "integrity": "sha512-CIfGzTelbKNEnLpLdGFgdyKhG23CKdKgQPOBc+OUNrkJ2vr+KSzsSV5kq5iWhEQbok+quxgGzrAtGWCyU7tHnA==",
"dev": true "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": { "undici": {
"version": "5.13.0", "version": "5.13.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-5.13.0.tgz",
@ -19304,6 +19393,11 @@
"integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==", "integrity": "sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==",
"dev": true "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": { "wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "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/leaflet": "^1.7.10",
"@types/lodash": "^4.14.182", "@types/lodash": "^4.14.182",
"@types/lodash-es": "^4.17.6", "@types/lodash-es": "^4.17.6",
"@types/luxon": "^3.1.0",
"@types/socket.io-client": "^3.0.0", "@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^5.27.0", "@typescript-eslint/eslint-plugin": "^5.27.0",
"@typescript-eslint/parser": "^5.27.0", "@typescript-eslint/parser": "^5.27.0",
@ -62,9 +63,11 @@
"cookie": "^0.4.2", "cookie": "^0.4.2",
"copy-image-clipboard": "^2.1.2", "copy-image-clipboard": "^2.1.2",
"exifr": "^7.1.3", "exifr": "^7.1.3",
"handlebars": "^4.7.7",
"leaflet": "^1.8.0", "leaflet": "^1.8.0",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.21",
"luxon": "^3.1.1",
"socket.io-client": "^4.5.1", "socket.io-client": "^4.5.1",
"svelte-keydown": "^0.5.0", "svelte-keydown": "^0.5.0",
"svelte-material-icons": "^2.0.2" "svelte-material-icons": "^2.0.2"

View File

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@ -1443,6 +1443,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto * @memberof SystemConfigDto
*/ */
'oauth': SystemConfigOAuthDto; 'oauth': SystemConfigOAuthDto;
/**
*
* @type {SystemConfigStorageTemplateDto}
* @memberof SystemConfigDto
*/
'storageTemplate': SystemConfigStorageTemplateDto;
} }
/** /**
* *
@ -1530,6 +1536,68 @@ export interface SystemConfigOAuthDto {
*/ */
'autoRegister': boolean; '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 * @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); setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {}; let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers}; localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@ -5388,6 +5489,15 @@ export const SystemConfigApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getDefaults(options); const localVarAxiosArgs = await localVarAxiosParamCreator.getDefaults(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); 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 * @param {SystemConfigDto} systemConfigDto
@ -5424,6 +5534,14 @@ export const SystemConfigApiFactory = function (configuration?: Configuration, b
getDefaults(options?: any): AxiosPromise<SystemConfigDto> { getDefaults(options?: any): AxiosPromise<SystemConfigDto> {
return localVarFp.getDefaults(options).then((request) => request(axios, basePath)); 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 * @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)); 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 * @param {SystemConfigDto} systemConfigDto

View File

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -4,7 +4,7 @@
* Immich * Immich
* Immich API * 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). * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

View File

@ -59,11 +59,11 @@ input:focus-visible {
@layer utilities { @layer utilities {
.immich-form-input { .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 { .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 { .immich-btn-primary {

View File

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

View File

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

View File

@ -6,11 +6,11 @@
export let showResetToDefault = true; export let showResetToDefault = true;
</script> </script>
<div class="flex justify-between gap-2 mx-4 mt-8"> <div class="flex justify-between gap-2 mt-8">
<div class="left"> <div class="left">
{#if showResetToDefault} {#if showResetToDefault}
<button <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" 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 Reset to default
@ -20,7 +20,7 @@
<div class="right"> <div class="right">
<button <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" 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 >Reset
</button> </button>

View File

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

View File

@ -7,7 +7,7 @@
<div class="flex justify-between mx-4 place-items-center"> <div class="flex justify-between mx-4 place-items-center">
<div> <div>
<h2 class="immich-form-label"> <h2 class="immich-form-label text-sm">
{title.toUpperCase()} {title.toUpperCase()}
</h2> </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 FFmpegSettings from '$lib/components/admin-page/settings/ffmpeg/ffmpeg-settings.svelte';
import OAuthSettings from '$lib/components/admin-page/settings/oauth/oauth-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 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 LoadingSpinner from '$lib/components/shared-components/loading-spinner.svelte';
import { api, SystemConfigDto } from '@api'; import { api, SystemConfigDto } from '@api';
import type { PageData } from './$types';
let systemConfig: SystemConfigDto; let systemConfig: SystemConfigDto;
export let data: PageData;
const getConfig = async () => { const getConfig = async () => {
const { data } = await api.systemConfigApi.getConfig(); const { data } = await api.systemConfigApi.getConfig();
systemConfig = data; systemConfig = data;
@ -33,5 +35,12 @@
<SettingAccordion title="OAuth Settings" subtitle="Manage the OAuth integration to Immich app"> <SettingAccordion title="OAuth Settings" subtitle="Manage the OAuth integration to Immich app">
<OAuthSettings oauthConfig={configs.oauth} /> <OAuthSettings oauthConfig={configs.oauth} />
</SettingAccordion> </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} {/await}
</section> </section>

View File

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