Compare commits

...

10 Commits

Author SHA1 Message Date
renovate[bot]
0b253a8a2c
Merge ae26c3d203b33e1a72b848df66caf8f9ff0588f6 into 63437529e1224f5e7879ce567b1a4502fb97573b 2024-10-02 03:04:21 +07:00
Jason Rasmussen
63437529e1
refactor(server): config file env (#13100) 2024-10-01 16:03:55 -04:00
Jason Rasmussen
4d20b11f25
feat: track upgrade history (#13097) 2024-10-01 13:33:58 -04:00
renovate[bot]
1c3603e23b
chore(deps): update grafana/grafana docker tag to v11.2.1 (#13094)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 13:06:45 -04:00
renovate[bot]
eb3ac09e0d
chore(deps): update dependency svelte-check to v4.0.3 (#13090)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 13:05:33 -04:00
Jason Rasmussen
305fc77ebe
feat(server): better mount checks (#13092) 2024-10-01 13:04:37 -04:00
Zack Pollard
d46e50213a
fix(server): offline assets don't restore when coming back online (#13087) 2024-10-01 14:03:19 +01:00
renovate[bot]
49486f2d26
chore(deps): update base-image to v20241001 (major) (#13089)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 12:26:00 +00:00
renovate[bot]
eac189a9e5
chore(deps): update dependency prettier-plugin-svelte to v3.2.7 (#13088)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-10-01 12:25:08 +00:00
renovate[bot]
ae26c3d203
chore(deps): update dependency flutter_launcher_icons to ^0.14.0 2024-10-01 02:10:01 +00:00
73 changed files with 875 additions and 181 deletions

View File

@ -91,7 +91,7 @@ services:
command: ['./run.sh', '-disable-reporting']
ports:
- 3000:3000
image: grafana/grafana:11.2.0-ubuntu@sha256:8e2c13739563c3da9d45de96c6bcb63ba617cac8c571c060112c7fc8ad6914e9
image: grafana/grafana:11.2.1-ubuntu@sha256:b90c0fdc482913de7a55fe96539bf9e3c4fbcee835d0c2dffc59152bc3964ff7
volumes:
- grafana-data:/var/lib/grafana

View File

@ -0,0 +1,47 @@
# System Integrity
## Folder checks
:::info
The folders considered for these checks include: `upload/`, `library/`, `thumbs/`, `encoded-video/`, `profile/`
:::
When Immich starts, it performs a series of checks in order to validate that it can read and write files to the volume mounts used by the storage system. If it cannot perform all the required operations, it will fail to start. The checks include:
- Creating an initial hidden file (`.immich`) in each folder
- Reading a hidden file (`.immich`) in each folder
- Overwriting a hidden file (`.immich`) in each folder
The checks are designed to catch the following situations:
- Incorrect permissions (cannot read/write files)
- Missing volume mount (`.immich` files should exist, but are missing)
### Common issues
:::note
`.immich` files serve as markers and help keep track of volume mounts being used by Immich. Except for the situations listed below, they should never be manually created or deleted.
:::
#### Missing `.immich` files
```
Verifying system mount folder checks (enabled=true)
...
ENOENT: no such file or directory, open 'upload/encoded-video/.immich'
```
The above error messages show that the server has previously (successfully) written `.immich` files to each folder, but now does not detect them. This could be because any of the following:
- Permission error - unable to read the file, but it exists
- File does not exist - volume mount has changed and should be corrected
- File does not exist - user manually deleted it and should be manually re-created (`touch .immich`)
- File does not exist - user restored from a backup, but did not restore each folder (user should restore all folders or manually create `.immich` in any missing folders)
### Ignoring the checks
The checks are designed to catch common problems that we have seen users have in the past, but if you want to disable them you can set the following environment variable:
```
IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
```

View File

@ -42,6 +42,7 @@ These environment variables are used by the `docker-compose.yml` file and do **N
| `IMMICH_MICROSERVICES_METRICS_PORT` | Port for the OTEL metrics | `8082` | server | microservices |
| `IMMICH_PROCESS_INVALID_IMAGES` | When `true`, generate thumbnails for invalid images | | server | microservices |
| `IMMICH_TRUSTED_PROXIES` | List of comma separated IPs set as trusted proxies | | server | api |
| `IMMICH_IGNORE_MOUNT_CHECK_ERRORS` | See [System Integrity](/docs/administration/system-integrity) | | server | api, microservices |
\*1: `TZ` should be set to a `TZ identifier` from [this list][tz-list]. For example, `TZ="Etc/UTC"`.
`TZ` is used by `exiftool` as a fallback in case the timezone cannot be determined from the image metadata. It is also used for logfile timestamps and cron job execution.

View File

@ -183,6 +183,7 @@ Class | Method | HTTP request | Description
*ServerApi* | [**getStorage**](doc//ServerApi.md#getstorage) | **GET** /server/storage |
*ServerApi* | [**getSupportedMediaTypes**](doc//ServerApi.md#getsupportedmediatypes) | **GET** /server/media-types |
*ServerApi* | [**getTheme**](doc//ServerApi.md#gettheme) | **GET** /server/theme |
*ServerApi* | [**getVersionHistory**](doc//ServerApi.md#getversionhistory) | **GET** /server/version-history |
*ServerApi* | [**pingServer**](doc//ServerApi.md#pingserver) | **GET** /server/ping |
*ServerApi* | [**setServerLicense**](doc//ServerApi.md#setserverlicense) | **PUT** /server/license |
*SessionsApi* | [**deleteAllSessions**](doc//SessionsApi.md#deleteallsessions) | **DELETE** /sessions |
@ -400,6 +401,7 @@ Class | Method | HTTP request | Description
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerStorageResponseDto](doc//ServerStorageResponseDto.md)
- [ServerThemeDto](doc//ServerThemeDto.md)
- [ServerVersionHistoryResponseDto](doc//ServerVersionHistoryResponseDto.md)
- [ServerVersionResponseDto](doc//ServerVersionResponseDto.md)
- [SessionResponseDto](doc//SessionResponseDto.md)
- [SharedLinkCreateDto](doc//SharedLinkCreateDto.md)

View File

@ -213,6 +213,7 @@ part 'model/server_ping_response.dart';
part 'model/server_stats_response_dto.dart';
part 'model/server_storage_response_dto.dart';
part 'model/server_theme_dto.dart';
part 'model/server_version_history_response_dto.dart';
part 'model/server_version_response_dto.dart';
part 'model/session_response_dto.dart';
part 'model/shared_link_create_dto.dart';

View File

@ -418,6 +418,50 @@ class ServerApi {
return null;
}
/// Performs an HTTP 'GET /server/version-history' operation and returns the [Response].
Future<Response> getVersionHistoryWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/server/version-history';
// 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<List<ServerVersionHistoryResponseDto>?> getVersionHistory() async {
final response = await getVersionHistoryWithHttpInfo();
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) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<ServerVersionHistoryResponseDto>') as List)
.cast<ServerVersionHistoryResponseDto>()
.toList(growable: false);
}
return null;
}
/// Performs an HTTP 'GET /server/ping' operation and returns the [Response].
Future<Response> pingServerWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@ -480,6 +480,8 @@ class ApiClient {
return ServerStorageResponseDto.fromJson(value);
case 'ServerThemeDto':
return ServerThemeDto.fromJson(value);
case 'ServerVersionHistoryResponseDto':
return ServerVersionHistoryResponseDto.fromJson(value);
case 'ServerVersionResponseDto':
return ServerVersionResponseDto.fromJson(value);
case 'SessionResponseDto':

View File

@ -0,0 +1,115 @@
//
// AUTO-GENERATED FILE, DO NOT MODIFY!
//
// @dart=2.18
// 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 ServerVersionHistoryResponseDto {
/// Returns a new [ServerVersionHistoryResponseDto] instance.
ServerVersionHistoryResponseDto({
required this.createdAt,
required this.id,
required this.version,
});
DateTime createdAt;
String id;
String version;
@override
bool operator ==(Object other) => identical(this, other) || other is ServerVersionHistoryResponseDto &&
other.createdAt == createdAt &&
other.id == id &&
other.version == version;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(createdAt.hashCode) +
(id.hashCode) +
(version.hashCode);
@override
String toString() => 'ServerVersionHistoryResponseDto[createdAt=$createdAt, id=$id, version=$version]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'createdAt'] = this.createdAt.toUtc().toIso8601String();
json[r'id'] = this.id;
json[r'version'] = this.version;
return json;
}
/// Returns a new [ServerVersionHistoryResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static ServerVersionHistoryResponseDto? fromJson(dynamic value) {
upgradeDto(value, "ServerVersionHistoryResponseDto");
if (value is Map) {
final json = value.cast<String, dynamic>();
return ServerVersionHistoryResponseDto(
createdAt: mapDateTime(json, r'createdAt', r'')!,
id: mapValueOfType<String>(json, r'id')!,
version: mapValueOfType<String>(json, r'version')!,
);
}
return null;
}
static List<ServerVersionHistoryResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <ServerVersionHistoryResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = ServerVersionHistoryResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, ServerVersionHistoryResponseDto> mapFromJson(dynamic json) {
final map = <String, ServerVersionHistoryResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = ServerVersionHistoryResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of ServerVersionHistoryResponseDto-objects as value to a dart map
static Map<String, List<ServerVersionHistoryResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<ServerVersionHistoryResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = ServerVersionHistoryResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'createdAt',
'id',
'version',
};
}

View File

@ -532,10 +532,10 @@ packages:
dependency: "direct dev"
description:
name: flutter_launcher_icons
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
sha256: "619817c4b65b322b5104b6bb6dfe6cda62d9729bd7ad4303ecc8b4e690a67a77"
url: "https://pub.dev"
source: hosted
version: "0.13.1"
version: "0.14.1"
flutter_lints:
dependency: "direct dev"
description:

View File

@ -90,7 +90,7 @@ dev_dependencies:
flutter_lints: ^5.0.0
build_runner: ^2.4.8
auto_route_generator: ^9.0.0
flutter_launcher_icons: ^0.13.1
flutter_launcher_icons: ^0.14.0
flutter_native_splash: ^2.3.9
isar_generator: ^3.1.0+1
integration_test:

View File

@ -5088,6 +5088,30 @@
]
}
},
"/server/version-history": {
"get": {
"operationId": "getVersionHistory",
"parameters": [],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/ServerVersionHistoryResponseDto"
},
"type": "array"
}
}
},
"description": ""
}
},
"tags": [
"Server"
]
}
},
"/sessions": {
"delete": {
"operationId": "deleteAllSessions",
@ -11042,6 +11066,26 @@
],
"type": "object"
},
"ServerVersionHistoryResponseDto": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"id": {
"type": "string"
},
"version": {
"type": "string"
}
},
"required": [
"createdAt",
"id",
"version"
],
"type": "object"
},
"ServerVersionResponseDto": {
"properties": {
"major": {

View File

@ -1000,6 +1000,11 @@ export type ServerVersionResponseDto = {
minor: number;
patch: number;
};
export type ServerVersionHistoryResponseDto = {
createdAt: string;
id: string;
version: string;
};
export type SessionResponseDto = {
createdAt: string;
current: boolean;
@ -2667,6 +2672,14 @@ export function getServerVersion(opts?: Oazapfts.RequestOpts) {
...opts
}));
}
export function getVersionHistory(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: ServerVersionHistoryResponseDto[];
}>("/server/version-history", {
...opts
}));
}
export function deleteAllSessions(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/sessions", {
...opts,

View File

@ -1,5 +1,5 @@
# dev build
FROM ghcr.io/immich-app/base-server-dev:20240924@sha256:fff4358d435065a626c64a4c015cbfce6ee714b05fabe39aa0d83d8cff3951f2 AS dev
FROM ghcr.io/immich-app/base-server-dev:20241001@sha256:bb10832c2567f5625df68bb790523e85a358031ddcb3d7ac98b669f62ed8de27 AS dev
RUN apt-get install --no-install-recommends -yqq tini
WORKDIR /usr/src/app
@ -41,7 +41,7 @@ RUN npm run build
# prod build
FROM ghcr.io/immich-app/base-server-prod:20240924@sha256:af3089fe48d7ff162594bd7edfffa56ba4e7014ad10ad69c4ebfd428e39b06ff
FROM ghcr.io/immich-app/base-server-prod:20241001@sha256:a9a0745a486e9cbd73fa06b49168e985f8f2c1be0fca9fb0a8e06916246c7087
WORKDIR /usr/src/app
ENV NODE_ENV=production \

View File

@ -10,6 +10,7 @@ import {
ServerStatsResponseDto,
ServerStorageResponseDto,
ServerThemeDto,
ServerVersionHistoryResponseDto,
ServerVersionResponseDto,
} from 'src/dtos/server.dto';
import { Authenticated } from 'src/middleware/auth.guard';
@ -46,6 +47,11 @@ export class ServerController {
return this.versionService.getVersion();
}
@Get('version-history')
getVersionHistory(): Promise<ServerVersionHistoryResponseDto[]> {
return this.versionService.getVersionHistory();
}
@Get('features')
getServerFeatures(): Promise<ServerFeaturesDto> {
return this.service.getFeatures();

View File

@ -5,6 +5,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { PersonEntity } from 'src/entities/person.entity';
import { AssetFileType, AssetPathType, ImageFormat, PathType, PersonPathType, StorageFolder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMoveRepository } from 'src/interfaces/move.interface';
@ -36,6 +37,7 @@ let instance: StorageCore | null;
export class StorageCore {
private constructor(
private assetRepository: IAssetRepository,
private configRepository: IConfigRepository,
private cryptoRepository: ICryptoRepository,
private moveRepository: IMoveRepository,
private personRepository: IPersonRepository,
@ -46,6 +48,7 @@ export class StorageCore {
static create(
assetRepository: IAssetRepository,
configRepository: IConfigRepository,
cryptoRepository: ICryptoRepository,
moveRepository: IMoveRepository,
personRepository: IPersonRepository,
@ -56,6 +59,7 @@ export class StorageCore {
if (!instance) {
instance = new StorageCore(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,
@ -245,7 +249,11 @@ export class StorageCore {
this.logger.warn(`Unable to complete move. File size mismatch: ${newPathSize} !== ${oldPathSize}`);
return false;
}
const repos = { metadataRepo: this.systemMetadataRepository, logger: this.logger };
const repos = {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
};
const config = await getConfig(repos, { withCache: true });
if (assetInfo && config.storageTemplate.hashVerificationEnabled) {
const { checksum } = assetInfo;

View File

@ -68,6 +68,12 @@ export class ServerVersionResponseDto {
}
}
export class ServerVersionHistoryResponseDto {
id!: string;
createdAt!: Date;
version!: string;
}
export class UsageByUserDto {
@ApiProperty({ type: 'string' })
userId!: string;

View File

@ -25,6 +25,7 @@ import { SystemMetadataEntity } from 'src/entities/system-metadata.entity';
import { TagEntity } from 'src/entities/tag.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
export const entities = [
ActivityEntity,
@ -54,4 +55,5 @@ export const entities = [
UserMetadataEntity,
SessionEntity,
LibraryEntity,
VersionHistoryEntity,
];

View File

@ -0,0 +1,13 @@
import { Column, CreateDateColumn, Entity, PrimaryGeneratedColumn } from 'typeorm';
@Entity('version_history')
export class VersionHistoryEntity {
@PrimaryGeneratedColumn('uuid')
id!: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt!: Date;
@Column()
version!: string;
}

View File

@ -3,10 +3,14 @@ import { VectorExtension } from 'src/interfaces/database.interface';
export const IConfigRepository = 'IConfigRepository';
export interface EnvData {
configFile?: string;
database: {
skipMigrations: boolean;
vectorExtension: VectorExtension;
};
storage: {
ignoreMountCheckErrors: boolean;
};
}
export interface IConfigRepository {

View File

@ -17,6 +17,7 @@ export enum DatabaseLock {
Migrations = 200,
SystemFileMounts = 300,
StorageTemplateMigration = 420,
VersionHistory = 500,
CLIPDimSize = 512,
LibraryWatch = 1337,
GetSystemConfig = 69,

View File

@ -0,0 +1,9 @@
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
export const IVersionHistoryRepository = 'IVersionHistoryRepository';
export interface IVersionHistoryRepository {
create(version: Omit<VersionHistoryEntity, 'id' | 'createdAt'>): Promise<VersionHistoryEntity>;
getAll(): Promise<VersionHistoryEntity[]>;
getLatest(): Promise<VersionHistoryEntity | null>;
}

View File

@ -0,0 +1,14 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddVersionHistory1727797340951 implements MigrationInterface {
name = 'AddVersionHistory1727797340951'
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`CREATE TABLE "version_history" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "version" character varying NOT NULL, CONSTRAINT "PK_5db259cbb09ce82c0d13cfd1b23" PRIMARY KEY ("id"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "version_history"`);
}
}

View File

@ -6,10 +6,14 @@ import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
export class ConfigRepository implements IConfigRepository {
getEnv(): EnvData {
return {
configFile: process.env.IMMICH_CONFIG_FILE,
database: {
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
vectorExtension: getVectorExtension(),
},
storage: {
ignoreMountCheckErrors: process.env.IMMICH_IGNORE_MOUNT_CHECK_ERRORS === 'true',
},
};
}
}

View File

@ -32,6 +32,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { ITagRepository } from 'src/interfaces/tag.interface';
import { ITrashRepository } from 'src/interfaces/trash.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { IViewRepository } from 'src/interfaces/view.interface';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
@ -67,6 +68,7 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { TagRepository } from 'src/repositories/tag.repository';
import { TrashRepository } from 'src/repositories/trash.repository';
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { ViewRepository } from 'src/repositories/view-repository';
export const repositories = [
@ -104,5 +106,6 @@ export const repositories = [
{ provide: ITagRepository, useClass: TagRepository },
{ provide: ITrashRepository, useClass: TrashRepository },
{ provide: IUserRepository, useClass: UserRepository },
{ provide: IVersionHistoryRepository, useClass: VersionHistoryRepository },
{ provide: IViewRepository, useClass: ViewRepository },
];

View File

@ -0,0 +1,25 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { VersionHistoryEntity } from 'src/entities/version-history.entity';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { Instrumentation } from 'src/utils/instrumentation';
import { Repository } from 'typeorm';
@Instrumentation()
@Injectable()
export class VersionHistoryRepository implements IVersionHistoryRepository {
constructor(@InjectRepository(VersionHistoryEntity) private repository: Repository<VersionHistoryEntity>) {}
async getAll(): Promise<VersionHistoryEntity[]> {
return this.repository.find({ order: { createdAt: 'DESC' } });
}
async getLatest(): Promise<VersionHistoryEntity | null> {
const results = await this.repository.find({ order: { createdAt: 'DESC' }, take: 1 });
return results[0] || null;
}
create(version: Omit<VersionHistoryEntity, 'id' | 'createdAt'>): Promise<VersionHistoryEntity> {
return this.repository.save(version);
}
}

View File

@ -4,6 +4,7 @@ import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType } from 'src/enum';
import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -19,6 +20,7 @@ import { partnerStub } from 'test/fixtures/partner.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -45,6 +47,7 @@ describe(AssetService.name, () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let userMock: Mocked<IUserRepository>;
let eventMock: Mocked<IEventRepository>;
@ -66,6 +69,7 @@ describe(AssetService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
userMock = newUserRepositoryMock();
@ -77,6 +81,7 @@ describe(AssetService.name, () => {
sut = new AssetService(
accessMock,
assetMock,
configMock,
jobMock,
systemMock,
userMock,

View File

@ -22,6 +22,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, Permission } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
IAssetDeleteJob,
@ -46,6 +47,7 @@ export class AssetService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@ -54,7 +56,7 @@ export class AssetService extends BaseService {
@Inject(IStackRepository) private stackRepository: IStackRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AssetService.name);
}

View File

@ -5,6 +5,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -20,6 +21,7 @@ import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newKeyRepositoryMock } from 'test/repositories/api-key.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -57,6 +59,7 @@ const oauthUserWithDefaultQuota = {
describe('AuthService', () => {
let sut: AuthService;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let eventMock: Mocked<IEventRepository>;
let userMock: Mocked<IUserRepository>;
@ -89,6 +92,7 @@ describe('AuthService', () => {
}),
} as any);
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
eventMock = newEventRepositoryMock();
userMock = newUserRepositoryMock();
@ -98,7 +102,17 @@ describe('AuthService', () => {
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
sut = new AuthService(cryptoMock, eventMock, systemMock, loggerMock, userMock, sessionMock, shareMock, keyMock);
sut = new AuthService(
configMock,
cryptoMock,
eventMock,
systemMock,
loggerMock,
userMock,
sessionMock,
shareMock,
keyMock,
);
});
it('should be defined', () => {

View File

@ -31,6 +31,7 @@ import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AuthType, Permission } from 'src/enum';
import { IKeyRepository } from 'src/interfaces/api-key.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -72,6 +73,7 @@ export type ValidateRequest = {
@Injectable()
export class AuthService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@ -81,7 +83,7 @@ export class AuthService extends BaseService {
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(AuthService.name);
custom.setHttpOptionsDefaults({ timeout: 30_000 });

View File

@ -1,32 +1,30 @@
import { Inject } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { getConfig, updateConfig } from 'src/utils/config';
export class BaseService {
constructor(
@Inject(IConfigRepository) protected configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) protected systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) protected logger: ILoggerRepository,
) {}
getConfig(options: { withCache: boolean }) {
return getConfig(
{
private get repos() {
return {
configRepo: this.configRepository,
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
},
options,
);
};
}
getConfig(options: { withCache: boolean }) {
return getConfig(this.repos, options);
}
updateConfig(newConfig: SystemConfig) {
return updateConfig(
{
metadataRepo: this.systemMetadataRepository,
logger: this.logger,
},
newConfig,
);
return updateConfig(this.repos, newConfig);
}
}

View File

@ -1,9 +1,11 @@
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { CliService } from 'src/services/cli.service';
import { userStub } from 'test/fixtures/user.stub';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
@ -13,18 +15,20 @@ import { Mocked, describe, it } from 'vitest';
describe(CliService.name, () => {
let sut: CliService;
let userMock: Mocked<IUserRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let userMock: Mocked<IUserRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new CliService(cryptoMock, systemMock, userMock, loggerMock);
sut = new CliService(configMock, cryptoMock, systemMock, userMock, loggerMock);
});
describe('resetAdminPassword', () => {

View File

@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { SALT_ROUNDS } from 'src/constants';
import { UserAdminResponseDto, mapUserAdmin } from 'src/dtos/user.dto';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -10,12 +11,13 @@ import { BaseService } from 'src/services/base.service';
@Injectable()
export class CliService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(CliService.name);
}

View File

@ -7,7 +7,7 @@ import {
} from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { DatabaseService } from 'src/services/database.service';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { Mocked } from 'vitest';
@ -60,7 +60,9 @@ describe(DatabaseService.name, () => {
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
])('should work with $extensionName', ({ extension, extensionName }) => {
beforeEach(() => {
configMock.getEnv.mockReturnValue({ database: { skipMigrations: false, vectorExtension: extension } });
configMock.getEnv.mockReturnValue(
mockEnvData({ database: { skipMigrations: false, vectorExtension: extension } }),
);
});
it(`should start up successfully with ${extension}`, async () => {
@ -244,12 +246,14 @@ describe(DatabaseService.name, () => {
});
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
configMock.getEnv.mockReturnValue({
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTORS,
},
});
}),
);
await expect(sut.onBootstrap()).resolves.toBeUndefined();
@ -257,12 +261,14 @@ describe(DatabaseService.name, () => {
});
it(`should throw error if pgvector extension could not be created`, async () => {
configMock.getEnv.mockReturnValue({
configMock.getEnv.mockReturnValue(
mockEnvData({
database: {
skipMigrations: true,
vectorExtension: DatabaseExtension.VECTOR,
},
});
}),
);
databaseMock.getExtensionVersion.mockResolvedValue({
installedVersion: null,
availableVersion: minVersionInRange,

View File

@ -1,4 +1,5 @@
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -8,6 +9,7 @@ import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -20,6 +22,7 @@ vitest.useFakeTimers();
describe(SearchService.name, () => {
let sut: DuplicateService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let searchMock: Mocked<ISearchRepository>;
let loggerMock: Mocked<ILoggerRepository>;
@ -28,13 +31,14 @@ describe(SearchService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
sut = new DuplicateService(systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock);
sut = new DuplicateService(configMock, systemMock, searchMock, assetMock, loggerMock, cryptoMock, jobMock);
});
it('should work', () => {

View File

@ -4,6 +4,7 @@ import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto, mapDuplicateResponse } from 'src/dtos/duplicate.dto';
import { AssetEntity } from 'src/entities/asset.entity';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
@ -24,6 +25,7 @@ import { usePagination } from 'src/utils/pagination';
@Injectable()
export class DuplicateService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ISearchRepository) private searchRepository: ISearchRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@ -31,7 +33,7 @@ export class DuplicateService extends BaseService {
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(DuplicateService.name);
}

View File

@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { defaults } from 'src/config';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import {
IJobRepository,
@ -18,6 +19,7 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { JobService } from 'src/services/job.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -37,6 +39,7 @@ const makeMockHandlers = (status: JobStatus) => {
describe(JobService.name, () => {
let sut: JobService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let personMock: Mocked<IPersonRepository>;
@ -46,13 +49,14 @@ describe(JobService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
personMock = newPersonRepositoryMock();
metricMock = newMetricRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new JobService(assetMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock);
sut = new JobService(assetMock, configMock, eventMock, jobMock, systemMock, personMock, metricMock, loggerMock);
});
it('should work', () => {

View File

@ -5,6 +5,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { AllJobStatusResponseDto, JobCommandDto, JobCreateDto, JobStatusDto } from 'src/dtos/job.dto';
import { AssetType, ManualJobName } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import {
ConcurrentQueueName,
@ -49,6 +50,7 @@ export class JobService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@ -56,7 +58,7 @@ export class JobService extends BaseService {
@Inject(IMetricRepository) private metricRepository: IMetricRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(JobService.name);
}

View File

@ -5,6 +5,7 @@ import { mapLibrary } from 'src/dtos/library.dto';
import { UserEntity } from 'src/entities/user.entity';
import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import {
@ -26,6 +27,7 @@ import { libraryStub } from 'test/fixtures/library.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
@ -43,15 +45,17 @@ describe(LibraryService.name, () => {
let sut: LibraryService;
let assetMock: Mocked<IAssetRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let jobMock: Mocked<IJobRepository>;
let libraryMock: Mocked<ILibraryRepository>;
let storageMock: Mocked<IStorageRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
libraryMock = newLibraryRepositoryMock();
assetMock = newAssetRepositoryMock();
@ -63,12 +67,13 @@ describe(LibraryService.name, () => {
sut = new LibraryService(
assetMock,
systemMock,
configMock,
cryptoMock,
databaseMock,
jobMock,
libraryMock,
storageMock,
databaseMock,
systemMock,
loggerMock,
);

View File

@ -18,6 +18,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { LibraryEntity } from 'src/entities/library.entity';
import { AssetType } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
@ -48,15 +49,16 @@ export class LibraryService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(ILibraryRepository) private repository: ILibraryRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(LibraryService.name);
}
@ -140,9 +142,15 @@ export class LibraryService extends BaseService {
onAdd: (path) => {
const handler = async () => {
this.logger.debug(`File add event received for ${path} in library ${library.id}}`);
if (matcher(path)) {
const asset = await this.assetRepository.getByLibraryIdAndOriginalPath(library.id, path);
if (asset) {
await this.syncAssets(library, [asset.id]);
}
if (matcher(path)) {
await this.syncFiles(library, [path]);
}
}
};
return handlePromiseError(handler(), this.logger);
},
@ -604,7 +612,7 @@ export class LibraryService extends BaseService {
this.logger.log(`Scanning library ${library.id} for removed assets`);
const onlineAssets = usePagination(JOBS_LIBRARY_PAGINATION_SIZE, (pagination) =>
this.assetRepository.getAll(pagination, { libraryId: job.id }),
this.assetRepository.getAll(pagination, { libraryId: job.id, withDeleted: true }),
);
let assetCount = 0;

View File

@ -12,6 +12,7 @@ import {
VideoCodec,
} from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -26,6 +27,7 @@ import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -39,6 +41,7 @@ import { Mocked } from 'vitest';
describe(MediaService.name, () => {
let sut: MediaService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let mediaMock: Mocked<IMediaRepository>;
let moveMock: Mocked<IMoveRepository>;
@ -50,6 +53,7 @@ describe(MediaService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
mediaMock = newMediaRepositoryMock();
@ -61,6 +65,7 @@ describe(MediaService.name, () => {
sut = new MediaService(
assetMock,
configMock,
personMock,
jobMock,
mediaMock,

View File

@ -18,6 +18,7 @@ import {
VideoContainer,
} from 'src/enum';
import { IAssetRepository, UpsertFileOptions, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
@ -55,6 +56,7 @@ export class MediaService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@ -64,10 +66,11 @@ export class MediaService extends BaseService {
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MediaService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,

View File

@ -6,6 +6,7 @@ import { ExifEntity } from 'src/entities/exif.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
@ -29,6 +30,7 @@ import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
@ -46,10 +48,14 @@ import { newUserRepositoryMock } from 'test/repositories/user.repository.mock';
import { Mocked } from 'vitest';
describe(MetadataService.name, () => {
let sut: MetadataService;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoRepository: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let mapMock: Mocked<IMapRepository>;
let metadataMock: Mocked<IMetadataRepository>;
@ -57,16 +63,15 @@ describe(MetadataService.name, () => {
let mediaMock: Mocked<IMediaRepository>;
let personMock: Mocked<IPersonRepository>;
let storageMock: Mocked<IStorageRepository>;
let eventMock: Mocked<IEventRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let tagMock: Mocked<ITagRepository>;
let userMock: Mocked<IUserRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let tagMock: Mocked<ITagRepository>;
let sut: MetadataService;
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoRepository = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
mapMock = newMapRepositoryMock();
@ -85,9 +90,10 @@ describe(MetadataService.name, () => {
sut = new MetadataService(
albumMock,
assetMock,
eventMock,
configMock,
cryptoRepository,
databaseMock,
eventMock,
jobMock,
mapMock,
mediaMock,

View File

@ -15,6 +15,7 @@ import { PersonEntity } from 'src/entities/person.entity';
import { AssetType, SourceType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
@ -103,9 +104,10 @@ export class MetadataService extends BaseService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMapRepository) private mapRepository: IMapRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@ -118,10 +120,11 @@ export class MetadataService extends BaseService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(MetadataService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,

View File

@ -6,6 +6,7 @@ import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { AssetFileType, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -18,6 +19,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -66,6 +68,7 @@ const configs = {
describe(NotificationService.name, () => {
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let loggerMock: Mocked<ILoggerRepository>;
@ -77,6 +80,7 @@ describe(NotificationService.name, () => {
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
loggerMock = newLoggerRepositoryMock();
@ -85,6 +89,7 @@ describe(NotificationService.name, () => {
userMock = newUserRepositoryMock();
sut = new NotificationService(
configMock,
eventMock,
systemMock,
notificationMock,

View File

@ -5,6 +5,7 @@ import { SystemConfigSmtpDto } from 'src/dtos/system-config.dto';
import { AlbumEntity } from 'src/entities/album.entity';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import {
IEmailJob,
@ -28,6 +29,7 @@ import { getPreferences } from 'src/utils/preferences';
@Injectable()
export class NotificationService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(INotificationRepository) private notificationRepository: INotificationRepository,
@ -37,7 +39,7 @@ export class NotificationService extends BaseService {
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(NotificationService.name);
}

View File

@ -4,6 +4,7 @@ import { PersonResponseDto, mapFaces, mapPerson } from 'src/dtos/person.dto';
import { AssetFaceEntity } from 'src/entities/asset-face.entity';
import { CacheControl, Colorspace, ImageFormat, SourceType, SystemMetadataKey } from 'src/enum';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -23,6 +24,7 @@ import { personStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -67,6 +69,7 @@ const detectFaceMock: DetectedFaces = {
describe(PersonService.name, () => {
let accessMock: IAccessRepositoryMock;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>;
let machineLearningMock: Mocked<IMachineLearningRepository>;
@ -82,6 +85,7 @@ describe(PersonService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
jobMock = newJobRepositoryMock();
machineLearningMock = newMachineLearningRepositoryMock();
@ -92,9 +96,11 @@ describe(PersonService.name, () => {
searchMock = newSearchRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new PersonService(
accessMock,
assetMock,
configMock,
machineLearningMock,
moveMock,
mediaMock,

View File

@ -33,6 +33,7 @@ import {
} from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import {
IBaseJob,
@ -70,6 +71,7 @@ export class PersonService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IMachineLearningRepository) private machineLearningRepository: IMachineLearningRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IMediaRepository) private mediaRepository: IMediaRepository,
@ -81,10 +83,11 @@ export class PersonService extends BaseService {
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(PersonService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
repository,

View File

@ -1,6 +1,7 @@
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
@ -12,6 +13,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newMachineLearningRepositoryMock } from 'test/repositories/machine-learning.repository.mock';
import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock';
@ -25,6 +27,7 @@ vitest.useFakeTimers();
describe(SearchService.name, () => {
let sut: SearchService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let machineMock: Mocked<IMachineLearningRepository>;
let personMock: Mocked<IPersonRepository>;
@ -34,6 +37,7 @@ describe(SearchService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
personMock = newPersonRepositoryMock();
@ -41,7 +45,16 @@ describe(SearchService.name, () => {
partnerMock = newPartnerRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SearchService(systemMock, machineMock, personMock, searchMock, assetMock, partnerMock, loggerMock);
sut = new SearchService(
configMock,
systemMock,
machineMock,
personMock,
searchMock,
assetMock,
partnerMock,
loggerMock,
);
});
it('should work', () => {

View File

@ -17,6 +17,7 @@ import {
import { AssetEntity } from 'src/entities/asset.entity';
import { AssetOrder } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface';
import { IPartnerRepository } from 'src/interfaces/partner.interface';
@ -30,6 +31,7 @@ import { isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class SearchService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@Inject(IPersonRepository) private personRepository: IPersonRepository,
@ -38,7 +40,7 @@ export class SearchService extends BaseService {
@Inject(IPartnerRepository) private partnerRepository: IPartnerRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SearchService.name);
}

View File

@ -1,4 +1,5 @@
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@ -6,6 +7,7 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IUserRepository } from 'src/interfaces/user.interface';
import { ServerService } from 'src/services/server.service';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
@ -16,6 +18,7 @@ import { Mocked } from 'vitest';
describe(ServerService.name, () => {
let sut: ServerService;
let configMock: Mocked<IConfigRepository>;
let storageMock: Mocked<IStorageRepository>;
let userMock: Mocked<IUserRepository>;
let serverInfoMock: Mocked<IServerInfoRepository>;
@ -24,6 +27,7 @@ describe(ServerService.name, () => {
let cryptoMock: Mocked<ICryptoRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
serverInfoMock = newServerInfoRepositoryMock();
@ -31,7 +35,7 @@ describe(ServerService.name, () => {
loggerMock = newLoggerRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
sut = new ServerService(userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
sut = new ServerService(configMock, userMock, storageMock, systemMock, serverInfoMock, loggerMock, cryptoMock);
});
it('should work', () => {

View File

@ -15,6 +15,7 @@ import {
UsageByUserDto,
} from 'src/dtos/server.dto';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
@ -23,13 +24,13 @@ import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interf
import { IUserRepository, UserStatsQueryResponse } from 'src/interfaces/user.interface';
import { BaseService } from 'src/services/base.service';
import { asHumanReadable } from 'src/utils/bytes';
import { isUsingConfigFile } from 'src/utils/config';
import { mimeTypes } from 'src/utils/mime-types';
import { isDuplicateDetectionEnabled, isFacialRecognitionEnabled, isSmartSearchEnabled } from 'src/utils/misc';
@Injectable()
export class ServerService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@ -37,7 +38,7 @@ export class ServerService extends BaseService {
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(ServerService.name);
}
@ -91,6 +92,7 @@ export class ServerService extends BaseService {
async getFeatures(): Promise<ServerFeaturesDto> {
const { reverseGeocoding, metadata, map, machineLearning, trash, oauth, passwordLogin, notifications } =
await this.getConfig({ withCache: false });
const { configFile } = this.configRepository.getEnv();
return {
smartSearch: isSmartSearchEnabled(machineLearning),
@ -105,7 +107,7 @@ export class ServerService extends BaseService {
oauth: oauth.enabled,
oauthAutoLaunch: oauth.autoLaunch,
passwordLogin: passwordLogin.enabled,
configFile: isUsingConfigFile(),
configFile: !!configFile,
email: notifications.smtp.enabled,
};
}

View File

@ -3,6 +3,7 @@ import _ from 'lodash';
import { DEFAULT_EXTERNAL_DOMAIN } from 'src/constants';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { SharedLinkType } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -13,6 +14,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSharedLinkRepositoryMock } from 'test/repositories/shared-link.repository.mock';
@ -22,6 +24,7 @@ import { Mocked } from 'vitest';
describe(SharedLinkService.name, () => {
let sut: SharedLinkService;
let accessMock: IAccessRepositoryMock;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let shareMock: Mocked<ISharedLinkRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
@ -29,12 +32,13 @@ describe(SharedLinkService.name, () => {
beforeEach(() => {
accessMock = newAccessRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
shareMock = newSharedLinkRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
logMock = newLoggerRepositoryMock();
sut = new SharedLinkService(accessMock, cryptoMock, logMock, shareMock, systemMock);
sut = new SharedLinkService(accessMock, configMock, cryptoMock, logMock, shareMock, systemMock);
});
it('should work', () => {

View File

@ -15,6 +15,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { SharedLinkEntity } from 'src/entities/shared-link.entity';
import { Permission, SharedLinkType } from 'src/enum';
import { IAccessRepository } from 'src/interfaces/access.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISharedLinkRepository } from 'src/interfaces/shared-link.interface';
@ -27,12 +28,13 @@ import { OpenGraphTags } from 'src/utils/misc';
export class SharedLinkService extends BaseService {
constructor(
@Inject(IAccessRepository) private access: IAccessRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
@Inject(ISharedLinkRepository) private repository: ISharedLinkRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SharedLinkService.name);
}

View File

@ -1,5 +1,6 @@
import { SystemConfig } from 'src/config';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -11,6 +12,7 @@ import { getCLIPModelInfo } from 'src/utils/misc';
import { assetStub } from 'test/fixtures/asset.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -22,6 +24,7 @@ import { Mocked } from 'vitest';
describe(SmartInfoService.name, () => {
let sut: SmartInfoService;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let jobMock: Mocked<IJobRepository>;
let searchMock: Mocked<ISearchRepository>;
@ -31,13 +34,24 @@ describe(SmartInfoService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
configMock = newConfigRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
searchMock = newSearchRepositoryMock();
jobMock = newJobRepositoryMock();
machineMock = newMachineLearningRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SmartInfoService(assetMock, databaseMock, jobMock, machineMock, searchMock, systemMock, loggerMock);
sut = new SmartInfoService(
assetMock,
configMock,
databaseMock,
jobMock,
machineMock,
searchMock,
systemMock,
loggerMock,
);
assetMock.getByIds.mockResolvedValue([assetStub.image]);
});

View File

@ -2,6 +2,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { OnEvent } from 'src/decorators';
import { IAssetRepository, WithoutProperty } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
import {
@ -26,6 +27,7 @@ import { usePagination } from 'src/utils/pagination';
export class SmartInfoService extends BaseService {
constructor(
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IMachineLearningRepository) private machineLearning: IMachineLearningRepository,
@ -33,7 +35,7 @@ export class SmartInfoService extends BaseService {
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SmartInfoService.name);
}

View File

@ -4,6 +4,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { JobStatus } from 'src/interfaces/job.interface';
@ -19,6 +20,7 @@ import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -33,6 +35,7 @@ describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let albumMock: Mocked<IAlbumRepository>;
let assetMock: Mocked<IAssetRepository>;
let configMock: Mocked<IConfigRepository>;
let cryptoMock: Mocked<ICryptoRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let moveMock: Mocked<IMoveRepository>;
@ -49,6 +52,7 @@ describe(StorageTemplateService.name, () => {
beforeEach(() => {
assetMock = newAssetRepositoryMock();
albumMock = newAlbumRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
moveMock = newMoveRepositoryMock();
@ -63,6 +67,7 @@ describe(StorageTemplateService.name, () => {
sut = new StorageTemplateService(
albumMock,
assetMock,
configMock,
systemMock,
moveMock,
personMock,

View File

@ -18,6 +18,7 @@ import { AssetEntity } from 'src/entities/asset.entity';
import { AssetPathType, AssetType, StorageFolder } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf } from 'src/interfaces/event.interface';
@ -63,6 +64,7 @@ export class StorageTemplateService extends BaseService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IMoveRepository) moveRepository: IMoveRepository,
@Inject(IPersonRepository) personRepository: IPersonRepository,
@ -72,10 +74,11 @@ export class StorageTemplateService extends BaseService {
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(StorageTemplateService.name);
this.storageCore = StorageCore.create(
assetRepository,
configRepository,
cryptoRepository,
moveRepository,
personRepository,

View File

@ -1,9 +1,11 @@
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { StorageService } from 'src/services/storage.service';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
@ -12,18 +14,20 @@ import { Mocked } from 'vitest';
describe(StorageService.name, () => {
let sut: StorageService;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let storageMock: Mocked<IStorageRepository>;
let loggerMock: Mocked<ILoggerRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
storageMock = newStorageRepositoryMock();
loggerMock = newLoggerRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock);
sut = new StorageService(configMock, databaseMock, storageMock, loggerMock, systemMock);
});
it('should work', () => {
@ -52,7 +56,7 @@ describe(StorageService.name, () => {
systemMock.get.mockResolvedValue({ mountFiles: true });
storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
await expect(sut.onBootstrap()).rejects.toThrow('Failed to read');
expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
expect(systemMock.set).not.toHaveBeenCalled();
@ -62,7 +66,21 @@ describe(StorageService.name, () => {
systemMock.get.mockResolvedValue({ mountFiles: true });
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
await expect(sut.onBootstrap()).rejects.toThrow('Failed to write');
expect(systemMock.set).not.toHaveBeenCalled();
});
it('should startup if checks are disabled', async () => {
systemMock.get.mockResolvedValue({ mountFiles: true });
configMock.getEnv.mockReturnValue(
mockEnvData({
storage: { ignoreMountCheckErrors: true },
}),
);
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(systemMock.set).not.toHaveBeenCalled();
});

View File

@ -3,6 +3,7 @@ import { join } from 'node:path';
import { StorageCore } from 'src/cores/storage.core';
import { OnEvent } from 'src/decorators';
import { StorageFolder, SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -10,9 +11,12 @@ import { IStorageRepository } from 'src/interfaces/storage.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { ImmichStartupError } from 'src/utils/events';
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
@Injectable()
export class StorageService {
constructor(
@Inject(IConfigRepository) private configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@ -23,12 +27,15 @@ export class StorageService {
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap() {
const envData = this.configRepository.getEnv();
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
const enabled = flags.mountFiles ?? false;
this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`);
try {
// check each folder exists and is writable
for (const folder of Object.values(StorageFolder)) {
if (!enabled) {
@ -47,6 +54,14 @@ export class StorageService {
}
this.logger.log('Successfully verified system mount folder checks');
} catch (error) {
if (envData.storage.ignoreMountCheckErrors) {
this.logger.error(error);
this.logger.warn('Ignoring mount folder errors');
} else {
throw error;
}
}
});
}
@ -70,49 +85,45 @@ export class StorageService {
}
private async verifyReadAccess(folder: StorageFolder) {
const { filePath } = this.getMountFilePaths(folder);
const { internalPath, externalPath } = this.getMountFilePaths(folder);
try {
await this.storageRepository.readFile(filePath);
await this.storageRepository.readFile(internalPath);
} catch (error) {
this.logger.error(`Failed to read ${filePath}: ${error}`);
this.logger.error(
`The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (read from "<MEDIA_LOCATION>/${folder}")`);
this.logger.error(`Failed to read ${internalPath}: ${error}`);
throw new ImmichStartupError(`Failed to read "${externalPath} - ${docsMessage}"`);
}
}
private async createMountFile(folder: StorageFolder) {
const { folderPath, filePath } = this.getMountFilePaths(folder);
const { folderPath, internalPath, externalPath } = this.getMountFilePaths(folder);
try {
this.storageRepository.mkdirSync(folderPath);
await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`));
await this.storageRepository.createFile(internalPath, Buffer.from(`${Date.now()}`));
} catch (error) {
this.logger.error(`Failed to create ${filePath}: ${error}`);
this.logger.error(
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
this.logger.warn('Found existing mount file, skipping creation');
return;
}
this.logger.error(`Failed to create ${internalPath}: ${error}`);
throw new ImmichStartupError(`Failed to create "${externalPath} - ${docsMessage}"`);
}
}
private async verifyWriteAccess(folder: StorageFolder) {
const { filePath } = this.getMountFilePaths(folder);
const { internalPath, externalPath } = this.getMountFilePaths(folder);
try {
await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`));
await this.storageRepository.overwriteFile(internalPath, Buffer.from(`${Date.now()}`));
} catch (error) {
this.logger.error(`Failed to write ${filePath}: ${error}`);
this.logger.error(
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
);
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
this.logger.error(`Failed to write ${internalPath}: ${error}`);
throw new ImmichStartupError(`Failed to write "${externalPath} - ${docsMessage}"`);
}
}
private getMountFilePaths(folder: StorageFolder) {
const folderPath = StorageCore.getBaseFolder(folder);
const filePath = join(folderPath, '.immich');
const internalPath = join(folderPath, '.immich');
const externalPath = `<UPLOAD_LOCATION>/${folder}/.immich`;
return { folderPath, filePath };
return { folderPath, internalPath, externalPath };
}
}

View File

@ -12,11 +12,13 @@ import {
VideoCodec,
VideoContainer,
} from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { QueueName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { SystemConfigService } from 'src/services/system-config.service';
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
@ -187,16 +189,18 @@ const updatedConfig = Object.freeze<SystemConfig>({
describe(SystemConfigService.name, () => {
let sut: SystemConfigService;
let configMock: Mocked<IConfigRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let eventMock: Mocked<IEventRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
delete process.env.IMMICH_CONFIG_FILE;
systemMock = newSystemMetadataRepositoryMock();
configMock = newConfigRepositoryMock();
eventMock = newEventRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new SystemConfigService(systemMock, eventMock, loggerMock);
sut = new SystemConfigService(configMock, eventMock, systemMock, loggerMock);
});
it('should work', () => {
@ -231,8 +235,7 @@ describe(SystemConfigService.name, () => {
});
it('should load the config from a json file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
await expect(sut.getSystemConfig()).resolves.toEqual(updatedConfig);
@ -241,7 +244,7 @@ describe(SystemConfigService.name, () => {
});
it('should log errors with the config file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(`{ "ffmpeg2": true, "ffmpeg2": true }`);
@ -256,7 +259,7 @@ describe(SystemConfigService.name, () => {
});
it('should load the config from a yaml file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = `
ffmpeg:
crf: 30
@ -275,7 +278,7 @@ describe(SystemConfigService.name, () => {
});
it('should accept an empty configuration file', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.getSystemConfig()).resolves.toEqual(defaults);
@ -284,7 +287,7 @@ describe(SystemConfigService.name, () => {
});
it('should allow underscores in the machine learning url', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { machineLearning: { url: 'immich_machine_learning' } };
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
@ -300,7 +303,7 @@ describe(SystemConfigService.name, () => {
for (const { should, externalDomain, result } of externalDomainTests) {
it(`should normalize an external domain ${should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
const partialConfig = { server: { externalDomain } };
systemMock.readFile.mockResolvedValue(JSON.stringify(partialConfig));
@ -310,7 +313,7 @@ describe(SystemConfigService.name, () => {
}
it('should warn for unknown options in yaml', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.yaml' }));
const partialConfig = `
unknownOption: true
`;
@ -331,7 +334,7 @@ describe(SystemConfigService.name, () => {
for (const test of tests) {
it(`should ${test.should}`, async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify(test.config));
if (test.warn) {
@ -390,7 +393,7 @@ describe(SystemConfigService.name, () => {
});
it('should throw an error if a config file is in use', async () => {
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
configMock.getEnv.mockReturnValue(mockEnvData({ configFile: 'immich-config.json' }));
systemMock.readFile.mockResolvedValue(JSON.stringify({}));
await expect(sut.updateSystemConfig(defaults)).rejects.toBeInstanceOf(BadRequestException);
expect(systemMock.set).not.toHaveBeenCalled();

View File

@ -15,21 +15,23 @@ import {
import { OnEvent } from 'src/decorators';
import { SystemConfigDto, SystemConfigTemplateStorageOptionDto, mapConfig } from 'src/dtos/system-config.dto';
import { LogLevel } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { BaseService } from 'src/services/base.service';
import { clearConfigCache, isUsingConfigFile } from 'src/utils/config';
import { clearConfigCache } from 'src/utils/config';
import { toPlainObject } from 'src/utils/object';
@Injectable()
export class SystemConfigService extends BaseService {
constructor(
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(SystemConfigService.name);
}
@ -67,7 +69,8 @@ export class SystemConfigService extends BaseService {
}
async updateSystemConfig(dto: SystemConfigDto): Promise<SystemConfigDto> {
if (isUsingConfigFile()) {
const { configFile } = this.configRepository.getEnv();
if (configFile) {
throw new BadRequestException('Cannot update configuration while IMMICH_CONFIG_FILE is in use');
}

View File

@ -2,6 +2,7 @@ import { BadRequestException, InternalServerErrorException, NotFoundException }
import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -14,6 +15,7 @@ import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { userStub } from 'test/fixtures/user.stub';
import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newCryptoRepositoryMock } from 'test/repositories/crypto.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
@ -34,6 +36,7 @@ describe(UserService.name, () => {
let cryptoRepositoryMock: Mocked<ICryptoRepository>;
let albumMock: Mocked<IAlbumRepository>;
let configMock: Mocked<IConfigRepository>;
let jobMock: Mocked<IJobRepository>;
let storageMock: Mocked<IStorageRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
@ -41,14 +44,24 @@ describe(UserService.name, () => {
beforeEach(() => {
albumMock = newAlbumRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
configMock = newConfigRepositoryMock();
cryptoRepositoryMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
userMock = newUserRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new UserService(albumMock, cryptoRepositoryMock, jobMock, storageMock, systemMock, userMock, loggerMock);
sut = new UserService(
albumMock,
configMock,
cryptoRepositoryMock,
jobMock,
storageMock,
systemMock,
userMock,
loggerMock,
);
userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null),

View File

@ -12,6 +12,7 @@ import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity';
import { CacheControl, StorageFolder, UserMetadataKey } from 'src/enum';
import { IAlbumRepository } from 'src/interfaces/album.interface';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { ICryptoRepository } from 'src/interfaces/crypto.interface';
import { IEntityJob, IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
@ -26,6 +27,7 @@ import { getPreferences, getPreferencesPartial, mergePreferences } from 'src/uti
export class UserService extends BaseService {
constructor(
@Inject(IAlbumRepository) private albumRepository: IAlbumRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
@ -33,7 +35,7 @@ export class UserService extends BaseService {
@Inject(IUserRepository) private userRepository: IUserRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(UserService.name);
}

View File

@ -1,17 +1,23 @@
import { DateTime } from 'luxon';
import { serverVersion } from 'src/constants';
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { IDatabaseRepository } from 'src/interfaces/database.interface';
import { IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { VersionService } from 'src/services/version.service';
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
import { newEventRepositoryMock } from 'test/repositories/event.repository.mock';
import { newJobRepositoryMock } from 'test/repositories/job.repository.mock';
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
import { newServerInfoRepositoryMock } from 'test/repositories/server-info.repository.mock';
import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock';
import { newVersionHistoryRepositoryMock } from 'test/repositories/version-history.repository.mock';
import { Mocked } from 'vitest';
const mockRelease = (version: string) => ({
@ -26,26 +32,58 @@ const mockRelease = (version: string) => ({
describe(VersionService.name, () => {
let sut: VersionService;
let configMock: Mocked<IConfigRepository>;
let databaseMock: Mocked<IDatabaseRepository>;
let eventMock: Mocked<IEventRepository>;
let jobMock: Mocked<IJobRepository>;
let serverMock: Mocked<IServerInfoRepository>;
let systemMock: Mocked<ISystemMetadataRepository>;
let versionMock: Mocked<IVersionHistoryRepository>;
let loggerMock: Mocked<ILoggerRepository>;
beforeEach(() => {
configMock = newConfigRepositoryMock();
databaseMock = newDatabaseRepositoryMock();
eventMock = newEventRepositoryMock();
jobMock = newJobRepositoryMock();
serverMock = newServerInfoRepositoryMock();
systemMock = newSystemMetadataRepositoryMock();
versionMock = newVersionHistoryRepositoryMock();
loggerMock = newLoggerRepositoryMock();
sut = new VersionService(eventMock, jobMock, serverMock, systemMock, loggerMock);
sut = new VersionService(
configMock,
databaseMock,
eventMock,
jobMock,
serverMock,
systemMock,
versionMock,
loggerMock,
);
});
it('should work', () => {
expect(sut).toBeDefined();
});
describe('onBootstrap', () => {
it('should record a new version', async () => {
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(versionMock.create).toHaveBeenCalledWith({ version: expect.any(String) });
});
it('should skip a duplicate version', async () => {
versionMock.getLatest.mockResolvedValue({
id: 'version-1',
createdAt: new Date(),
version: serverVersion.toString(),
});
await expect(sut.onBootstrap()).resolves.toBeUndefined();
expect(versionMock.create).not.toHaveBeenCalled();
});
});
describe('getVersion', () => {
it('should respond the server version', () => {
expect(sut.getVersion()).toEqual({
@ -56,6 +94,14 @@ describe(VersionService.name, () => {
});
});
describe('getVersionHistory', () => {
it('should respond the server version history', async () => {
const upgrade = { id: 'upgrade-1', createdAt: new Date(), version: '1.0.0' };
versionMock.getAll.mockResolvedValue([upgrade]);
await expect(sut.getVersionHistory()).resolves.toEqual([upgrade]);
});
});
describe('handQueueVersionCheck', () => {
it('should queue a version check job', async () => {
await expect(sut.handleQueueVersionCheck()).resolves.toBeUndefined();

View File

@ -6,11 +6,14 @@ import { OnEvent } from 'src/decorators';
import { ReleaseNotification, ServerVersionResponseDto } from 'src/dtos/server.dto';
import { VersionCheckMetadata } from 'src/entities/system-metadata.entity';
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
import { ArgOf, IEventRepository } from 'src/interfaces/event.interface';
import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { IServerInfoRepository } from 'src/interfaces/server-info.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { BaseService } from 'src/services/base.service';
const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): ReleaseNotification => {
@ -25,25 +28,41 @@ const asNotification = ({ checkedAt, releaseVersion }: VersionCheckMetadata): Re
@Injectable()
export class VersionService extends BaseService {
constructor(
@Inject(IConfigRepository) configRepository: IConfigRepository,
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
@Inject(IEventRepository) private eventRepository: IEventRepository,
@Inject(IJobRepository) private jobRepository: IJobRepository,
@Inject(IServerInfoRepository) private repository: IServerInfoRepository,
@Inject(ISystemMetadataRepository) systemMetadataRepository: ISystemMetadataRepository,
@Inject(IVersionHistoryRepository) private versionRepository: IVersionHistoryRepository,
@Inject(ILoggerRepository) logger: ILoggerRepository,
) {
super(systemMetadataRepository, logger);
super(configRepository, systemMetadataRepository, logger);
this.logger.setContext(VersionService.name);
}
@OnEvent({ name: 'app.bootstrap' })
async onBootstrap(): Promise<void> {
await this.handleVersionCheck();
await this.databaseRepository.withLock(DatabaseLock.VersionHistory, async () => {
const latest = await this.versionRepository.getLatest();
const current = serverVersion.toString();
if (!latest || latest.version !== current) {
this.logger.log(`Version has changed, adding ${current} to history`);
await this.versionRepository.create({ version: current });
}
});
}
getVersion() {
return ServerVersionResponseDto.fromSemVer(serverVersion);
}
getVersionHistory() {
return this.versionRepository.getAll();
}
async handleQueueVersionCheck() {
await this.jobRepository.queue({ name: JobName.VERSION_CHECK, data: {} });
}

View File

@ -6,6 +6,7 @@ import * as _ from 'lodash';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { SystemMetadataKey } from 'src/enum';
import { IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseLock } from 'src/interfaces/database.interface';
import { ILoggerRepository } from 'src/interfaces/logger.interface';
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
@ -15,6 +16,7 @@ import { DeepPartial } from 'typeorm';
export type SystemConfigValidator = (config: SystemConfig, newConfig: SystemConfig) => void | Promise<void>;
type RepoDeps = {
configRepo: IConfigRepository;
metadataRepo: ISystemMetadataRepository;
logger: ILoggerRepository;
};
@ -28,10 +30,6 @@ export const clearConfigCache = () => {
lastUpdated = null;
};
export const isUsingConfigFile = () => {
return !!process.env.IMMICH_CONFIG_FILE;
};
export const getConfig = async (repos: RepoDeps, { withCache }: { withCache: boolean }): Promise<SystemConfig> => {
if (!withCache || !config) {
const timestamp = lastUpdated;
@ -80,11 +78,12 @@ const loadFromFile = async ({ metadataRepo, logger }: RepoDeps, filepath: string
};
const buildConfig = async (repos: RepoDeps) => {
const { metadataRepo, logger } = repos;
const { configRepo, metadataRepo, logger } = repos;
const { configFile } = configRepo.getEnv();
// load partial
const partial = isUsingConfigFile()
? await loadFromFile(repos, process.env.IMMICH_CONFIG_FILE as string)
const partial = configFile
? await loadFromFile(repos, configFile)
: await metadataRepo.get(SystemMetadataKey.SYSTEM_CONFIG);
// merge with defaults
@ -106,7 +105,7 @@ const buildConfig = async (repos: RepoDeps) => {
// validate full config
const errors = await validate(plainToInstance(SystemConfigDto, config));
if (errors.length > 0) {
if (isUsingConfigFile()) {
if (configFile) {
throw new Error(`Invalid value(s) in file: ${errors}`);
} else {
logger.error('Validation error', errors);

View File

@ -1,14 +1,21 @@
import { IConfigRepository } from 'src/interfaces/config.interface';
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
import { DatabaseExtension } from 'src/interfaces/database.interface';
import { Mocked, vitest } from 'vitest';
export const newConfigRepositoryMock = (): Mocked<IConfigRepository> => {
return {
getEnv: vitest.fn().mockReturnValue({
const envData: EnvData = {
database: {
skipMigration: false,
skipMigrations: false,
vectorExtension: DatabaseExtension.VECTORS,
},
}),
storage: {
ignoreMountCheckErrors: false,
},
};
export const newConfigRepositoryMock = (): Mocked<IConfigRepository> => {
return {
getEnv: vitest.fn().mockReturnValue(envData),
};
};
export const mockEnvData = (config: Partial<EnvData>) => ({ ...envData, ...config });

View File

@ -0,0 +1,10 @@
import { IVersionHistoryRepository } from 'src/interfaces/version-history.interface';
import { Mocked, vitest } from 'vitest';
export const newVersionHistoryRepositoryMock = (): Mocked<IVersionHistoryRepository> => {
return {
getAll: vitest.fn().mockResolvedValue([]),
getLatest: vitest.fn(),
create: vitest.fn(),
};
};

50
web/package-lock.json generated
View File

@ -6129,9 +6129,9 @@
}
},
"node_modules/prettier-plugin-svelte": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.6.tgz",
"integrity": "sha512-Y1XWLw7vXUQQZmgv1JAEiLcErqUniAF2wO7QJsw8BVMvpLET2dI5WpEIEJx1r11iHVdSMzQxivyfrH9On9t2IQ==",
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.7.tgz",
"integrity": "sha512-/Dswx/ea0lV34If1eDcG3nulQ63YNr5KPDfMsjbdtpSWOxKKJ7nAc2qlVuYwEvCr4raIuredNoR7K4JCkmTGaQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -7085,14 +7085,14 @@
}
},
"node_modules/svelte-check": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.2.tgz",
"integrity": "sha512-w2yqcG9ELJe2RJCnAvB7v0OgkHhL3czzz/tVoxGFfO6y4mOrF6QHCDhXijeXzsU7LVKEwWS3Qd9tza4JBuDxqA==",
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-4.0.3.tgz",
"integrity": "sha512-V2eqOEuNrPi1jGf307opR1JZ+ITP6/7R8ALKSw4Uw3NWp6GfA+fe7tYtEvZc7QHCavYKBizCK4JFwYjbuPCeXQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "^0.3.25",
"chokidar": "^3.4.1",
"chokidar": "^4.0.1",
"fdir": "^6.2.0",
"picocolors": "^1.0.0",
"sade": "^1.7.4"
@ -7108,10 +7108,26 @@
"typescript": ">=5.0.0"
}
},
"node_modules/svelte-check/node_modules/chokidar": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz",
"integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"readdirp": "^4.0.1"
},
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-check/node_modules/fdir": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.3.0.tgz",
"integrity": "sha512-QOnuT+BOtivR77wYvCWHfGt9s4Pz1VIMbD463vegT5MLqNXy8rYFT/lPVEqf/bhYeT6qmqrNHhsX+rWwe3rOCQ==",
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.0.tgz",
"integrity": "sha512-3oB133prH1o4j/L5lLW7uOCF1PlD+/It2L0eL/iAqWMB91RBbqTewABqxhj0ibBd90EEmWZq7ntIWzVaWcXTGQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
@ -7138,6 +7154,20 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/svelte-check/node_modules/readdirp": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.1.tgz",
"integrity": "sha512-GkMg9uOTpIWWKbSsgwb5fA4EavTR+SG/PMPoAY8hkhHfEEY0/vqljY+XHqtDf2cr2IJtoNRDbrrEpZUiZCkYRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 14.16.0"
},
"funding": {
"type": "individual",
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/svelte-eslint-parser": {
"version": "0.41.1",
"resolved": "https://registry.npmjs.org/svelte-eslint-parser/-/svelte-eslint-parser-0.41.1.tgz",

View File

@ -1,19 +1,19 @@
<script lang="ts">
import FullScreenModal from '$lib/components/shared-components/full-screen-modal.svelte';
import Portal from '$lib/components/shared-components/portal/portal.svelte';
import { type ServerAboutResponseDto } from '@immich/sdk';
import { type ServerAboutResponseDto, type ServerVersionHistoryResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { t } from 'svelte-i18n';
export let onClose: () => void;
export let info: ServerAboutResponseDto;
export let versions: ServerVersionHistoryResponseDto[];
</script>
<Portal>
<FullScreenModal title={$t('about')} {onClose}>
<div
class="immich-scrollbar max-h-[500px] overflow-y-auto flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary"
>
<div class="flex flex-col sm:grid sm:grid-cols-2 gap-1 text-immich-primary dark:text-immich-dark-primary">
<div>
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-desc"
>Immich</label
@ -151,6 +151,35 @@
</div>
</div>
{/if}
<div class="col-span-full">
<label class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="version-history"
>{$t('version_history')}</label
>
<ul id="version-history" class="list-none">
{#each versions.slice(0, 5) as item (item.id)}
{@const createdAt = DateTime.fromISO(item.createdAt)}
<li>
<span
class="immich-form-label pb-2 text-xs"
id="version-history"
title={createdAt.toLocaleString(DateTime.DATETIME_SHORT_WITH_SECONDS)}
>
{$t('version_history_item', {
values: {
version: item.version,
date: createdAt.toLocaleString({
month: 'short',
day: 'numeric',
year: 'numeric',
}),
},
})}
</span>
</li>
{/each}
</ul>
</div>
</div>
</FullScreenModal>
</Portal>

View File

@ -4,7 +4,12 @@
import { requestServerInfo } from '$lib/utils/auth';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
import {
getAboutInfo,
getVersionHistory,
type ServerAboutResponseDto,
type ServerVersionHistoryResponseDto,
} from '@immich/sdk';
const { serverVersion, connected } = websocketStore;
@ -12,16 +17,17 @@
$: version = $serverVersion ? `v${$serverVersion.major}.${$serverVersion.minor}.${$serverVersion.patch}` : null;
let aboutInfo: ServerAboutResponseDto;
let info: ServerAboutResponseDto;
let versions: ServerVersionHistoryResponseDto[] = [];
onMount(async () => {
await requestServerInfo();
aboutInfo = await getAboutInfo();
[info, versions] = await Promise.all([getAboutInfo(), getVersionHistory()]);
});
</script>
{#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
<ServerAboutModal onClose={() => (isOpen = false)} {info} {versions} />
{/if}
<div

View File

@ -1,5 +1,4 @@
<script lang="ts">
import ServerAboutModal from '$lib/components/shared-components/server-about-modal.svelte';
import { locale } from '$lib/stores/preferences.store';
import { serverInfo } from '$lib/stores/server-info.store';
import { user } from '$lib/stores/user.store';
@ -8,18 +7,14 @@
import { t } from 'svelte-i18n';
import { getByteUnitString } from '../../../utils/byte-units';
import LoadingSpinner from '../loading-spinner.svelte';
import { getAboutInfo, type ServerAboutResponseDto } from '@immich/sdk';
let usageClasses = '';
let isOpen = false;
$: hasQuota = $user?.quotaSizeInBytes !== null;
$: availableBytes = (hasQuota ? $user?.quotaSizeInBytes : $serverInfo?.diskSizeRaw) || 0;
$: usedBytes = (hasQuota ? $user?.quotaUsageInBytes : $serverInfo?.diskUseRaw) || 0;
$: usedPercentage = Math.min(Math.round((usedBytes / availableBytes) * 100), 100);
let aboutInfo: ServerAboutResponseDto;
const onUpdate = () => {
usageClasses = getUsageClass();
};
@ -42,14 +37,9 @@
onMount(async () => {
await requestServerInfo();
aboutInfo = await getAboutInfo();
});
</script>
{#if isOpen}
<ServerAboutModal onClose={() => (isOpen = false)} info={aboutInfo} />
{/if}
<div
class="hidden md:block storage-status p-4 bg-gray-100 dark:bg-immich-dark-primary/10 ml-4 rounded-lg text-sm"
title={$t('storage_usage', {

View File

@ -1278,6 +1278,8 @@
"version": "Version",
"version_announcement_closing": "Your friend, Alex",
"version_announcement_message": "Hi friend, there is a new version of the application please take your time to visit the <link>release notes</link> and ensure your <code>docker-compose.yml</code>, and <code>.env</code> setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your application automatically.",
"version_history": "Version History",
"version_history_item": "Installed {version} on {date}",
"video": "Video",
"video_hover_setting": "Play video thumbnail on hover",
"video_hover_setting_description": "Play video thumbnail when mouse is hovering over item. Even when disabled, playback can be started by hovering over the play icon.",