mirror of
https://github.com/immich-app/immich.git
synced 2025-12-10 06:41:03 -08:00
Compare commits
34 Commits
v1.91.4
...
dev/metric
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b422bd0f7 | ||
|
|
f33a662f48 | ||
|
|
0232655da2 | ||
|
|
ac4c57247e | ||
|
|
fb01bd956f | ||
|
|
902d4d0275 | ||
|
|
db997f9173 | ||
|
|
e9197cde67 | ||
|
|
874f707c92 | ||
|
|
8fdd3aaed1 | ||
|
|
aaa7a613b2 | ||
|
|
45cf3291a2 | ||
|
|
612590feda | ||
|
|
19ea0ead85 | ||
|
|
e47e25e671 | ||
|
|
4dd7412a86 | ||
|
|
cc2dc12f6c | ||
|
|
2790a46703 | ||
|
|
f602295bf9 | ||
|
|
092a23fd7f | ||
|
|
154292242f | ||
|
|
8295542941 | ||
|
|
4505ebc315 | ||
|
|
19d66296fe | ||
|
|
64176d2ff4 | ||
|
|
cabc2d57dd | ||
|
|
5a3a2c7293 | ||
|
|
7e216809f3 | ||
|
|
81af48af7b | ||
|
|
4e06ccd052 | ||
|
|
234449f3c6 | ||
|
|
95a7bf7fac | ||
|
|
1c69dff967 | ||
|
|
f4c5bdfa1c |
2
.github/workflows/test.yml
vendored
2
.github/workflows/test.yml
vendored
@@ -209,7 +209,7 @@ jobs:
|
||||
poetry run black --check app export
|
||||
- name: Run mypy type checking
|
||||
run: |
|
||||
poetry run mypy --install-types --non-interactive --strict app/ export/
|
||||
poetry run mypy --install-types --non-interactive --strict app/
|
||||
- name: Run tests and coverage
|
||||
run: |
|
||||
poetry run pytest --cov app
|
||||
|
||||
@@ -102,7 +102,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
|
||||
私はこのプロジェクトにコミットしてきました。ドキュメントを更新し、新しい機能を追加し、バグを修正し続けるつもりですが、私ひとりではできません。だから、続けるためのモチベーションをさらに高めてくれる皆さんの助けが必要なのです。
|
||||
|
||||
[selfhosted.show - In the episode 'The-organization-must-いいえt-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
|
||||
[selfhosted.show - In the episode 'The-organization-must-not-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) のホストが言ったように、これはチームと私がやっていることの大規模な事業だ。そしていつの日か、フルタイムでこの仕事ができるようになりたいと思っています。
|
||||
|
||||
もし、あなたがこのプロジェクトに賛同し、このアプリを長く使い続けたいと思われるのであれば、以下のオプションから支援をご検討ください。
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
|
||||
|
||||
Ik ben trouw aan dit project en ik zal niet stoppen. Ik zal de documenten blijven bijwerken, nieuwe functies toevoegen en bugs oplossen. Maar ik kan het niet alleen. Ik heb dus jouw hulp nodig om mij extra motivatie te geven om door te gaan.
|
||||
|
||||
Als onze gastheren in de [selfhosted.show - In de aflevering 'The-organization-must-Neet-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) zeiden, dit is een eNeerme onderneming van wat het team en ik doen. En ik zou dit graag fulltime willen doen, ik vraag jouw hulp om dat mogelijk te maken.
|
||||
Als onze gastheren in de [selfhosted.show - In de aflevering 'The-organization-must-Neet-be-name is a Hostile Actor'](https://selfhosted.show/79?t=1418) zeiden, dit is een enorme onderneming van wat het team en ik doen. En ik zou dit graag fulltime willen doen, ik vraag jouw hulp om dat mogelijk te maken.
|
||||
|
||||
Als je denkt dat dit het juiste doel is en de app iets is dat je jezelf al heel lang ziet gebruiken, overweeg dan om het project te steunen met de onderstaande optie.
|
||||
|
||||
|
||||
7
cli/package-lock.json
generated
7
cli/package-lock.json
generated
@@ -15,6 +15,7 @@
|
||||
"commander": "^11.0.0",
|
||||
"form-data": "^4.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4354,8 +4355,7 @@
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
|
||||
},
|
||||
"node_modules/graphemer": {
|
||||
"version": "1.4.0",
|
||||
@@ -10877,8 +10877,7 @@
|
||||
"graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="
|
||||
},
|
||||
"graphemer": {
|
||||
"version": "1.4.0",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"commander": "^11.0.0",
|
||||
"form-data": "^4.0.0",
|
||||
"glob": "^10.3.1",
|
||||
"graceful-fs": "^4.2.11",
|
||||
"yaml": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
187
cli/src/api/open-api/api.ts
generated
187
cli/src/api/open-api/api.ts
generated
@@ -373,12 +373,6 @@ export interface AllJobStatusResponseDto {
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'migration': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
* @memberof AllJobStatusResponseDto
|
||||
*/
|
||||
'objectTagging': JobStatusDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobStatusDto}
|
||||
@@ -1318,39 +1312,6 @@ export interface CheckExistingAssetsResponseDto {
|
||||
*/
|
||||
'existingIds': Array<string>;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface ClassificationConfig
|
||||
*/
|
||||
export interface ClassificationConfig {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'enabled': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {number}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'minScore': number;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'modelName': string;
|
||||
/**
|
||||
*
|
||||
* @type {ModelType}
|
||||
* @memberof ClassificationConfig
|
||||
*/
|
||||
'modelType'?: ModelType;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -2015,7 +1976,6 @@ export const JobName = {
|
||||
ThumbnailGeneration: 'thumbnailGeneration',
|
||||
MetadataExtraction: 'metadataExtraction',
|
||||
VideoConversion: 'videoConversion',
|
||||
ObjectTagging: 'objectTagging',
|
||||
RecognizeFaces: 'recognizeFaces',
|
||||
SmartSearch: 'smartSearch',
|
||||
BackgroundTask: 'backgroundTask',
|
||||
@@ -2358,7 +2318,6 @@ export interface MergePersonDto {
|
||||
*/
|
||||
|
||||
export const ModelType = {
|
||||
ImageClassification: 'image-classification',
|
||||
FacialRecognition: 'facial-recognition',
|
||||
Clip: 'clip'
|
||||
} as const;
|
||||
@@ -3103,6 +3062,12 @@ export interface ServerFeaturesDto {
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'map': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'metrics': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@@ -3139,12 +3104,6 @@ export interface ServerFeaturesDto {
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'sidecar': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof ServerFeaturesDto
|
||||
*/
|
||||
'tagImage': boolean;
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
@@ -3613,6 +3572,12 @@ export interface SystemConfigDto {
|
||||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'map': SystemConfigMapDto;
|
||||
/**
|
||||
*
|
||||
* @type {SystemConfigMetricsDto}
|
||||
* @memberof SystemConfigDto
|
||||
*/
|
||||
'metrics': SystemConfigMetricsDto;
|
||||
/**
|
||||
*
|
||||
* @type {SystemConfigNewVersionCheckDto}
|
||||
@@ -3803,12 +3768,6 @@ export interface SystemConfigJobDto {
|
||||
* @memberof SystemConfigJobDto
|
||||
*/
|
||||
'migration': JobSettingsDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobSettingsDto}
|
||||
* @memberof SystemConfigJobDto
|
||||
*/
|
||||
'objectTagging': JobSettingsDto;
|
||||
/**
|
||||
*
|
||||
* @type {JobSettingsDto}
|
||||
@@ -3911,12 +3870,6 @@ export interface SystemConfigLoggingDto {
|
||||
* @interface SystemConfigMachineLearningDto
|
||||
*/
|
||||
export interface SystemConfigMachineLearningDto {
|
||||
/**
|
||||
*
|
||||
* @type {ClassificationConfig}
|
||||
* @memberof SystemConfigMachineLearningDto
|
||||
*/
|
||||
'classification': ClassificationConfig;
|
||||
/**
|
||||
*
|
||||
* @type {CLIPConfig}
|
||||
@@ -3967,6 +3920,19 @@ export interface SystemConfigMapDto {
|
||||
*/
|
||||
'lightStyle': string;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
* @interface SystemConfigMetricsDto
|
||||
*/
|
||||
export interface SystemConfigMetricsDto {
|
||||
/**
|
||||
*
|
||||
* @type {boolean}
|
||||
* @memberof SystemConfigMetricsDto
|
||||
*/
|
||||
'enabled': boolean;
|
||||
}
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
@@ -12751,6 +12717,109 @@ export class LibraryApi extends BaseAPI {
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* MetricsApi - axios parameter creator
|
||||
* @export
|
||||
*/
|
||||
export const MetricsApiAxiosParamCreator = function (configuration?: Configuration) {
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMetrics: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
const localVarPath = `/metrics`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
let baseOptions;
|
||||
if (configuration) {
|
||||
baseOptions = configuration.baseOptions;
|
||||
}
|
||||
|
||||
const localVarRequestOptions = { method: 'GET', ...baseOptions, ...options};
|
||||
const localVarHeaderParameter = {} as any;
|
||||
const localVarQueryParameter = {} as any;
|
||||
|
||||
// authentication cookie required
|
||||
|
||||
// authentication api_key required
|
||||
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
|
||||
|
||||
// authentication bearer required
|
||||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
|
||||
|
||||
setSearchParams(localVarUrlObj, localVarQueryParameter);
|
||||
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
|
||||
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
|
||||
|
||||
return {
|
||||
url: toPathString(localVarUrlObj),
|
||||
options: localVarRequestOptions,
|
||||
};
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* MetricsApi - functional programming interface
|
||||
* @export
|
||||
*/
|
||||
export const MetricsApiFp = function(configuration?: Configuration) {
|
||||
const localVarAxiosParamCreator = MetricsApiAxiosParamCreator(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getMetrics(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<object>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMetrics(options);
|
||||
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* MetricsApi - factory interface
|
||||
* @export
|
||||
*/
|
||||
export const MetricsApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
|
||||
const localVarFp = MetricsApiFp(configuration)
|
||||
return {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMetrics(options?: AxiosRequestConfig): AxiosPromise<object> {
|
||||
return localVarFp.getMetrics(options).then((request) => request(axios, basePath));
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* MetricsApi - object-oriented interface
|
||||
* @export
|
||||
* @class MetricsApi
|
||||
* @extends {BaseAPI}
|
||||
*/
|
||||
export class MetricsApi extends BaseAPI {
|
||||
/**
|
||||
*
|
||||
* @param {*} [options] Override http request option.
|
||||
* @throws {RequiredError}
|
||||
* @memberof MetricsApi
|
||||
*/
|
||||
public getMetrics(options?: AxiosRequestConfig) {
|
||||
return MetricsApiFp(this.configuration).getMetrics(options).then((request) => request(this.axios, this.basePath));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* OAuthApi - axios parameter creator
|
||||
* @export
|
||||
|
||||
@@ -22,6 +22,7 @@ export default class Upload extends BaseCommand {
|
||||
crawlOptions.pathsToCrawl = paths;
|
||||
crawlOptions.recursive = options.recursive;
|
||||
crawlOptions.exclusionPatterns = options.exclusionPatterns;
|
||||
crawlOptions.includeHidden = options.includeHidden;
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
@@ -61,6 +62,10 @@ export default class Upload extends BaseCommand {
|
||||
// Compute total size first
|
||||
await asset.process();
|
||||
totalSize += asset.fileSize;
|
||||
|
||||
if (options.albumName) {
|
||||
asset.albumName = options.albumName;
|
||||
}
|
||||
}
|
||||
|
||||
const existingAlbums = (await this.immichApi.albumApi.getAllAlbums()).data;
|
||||
@@ -75,6 +80,10 @@ export default class Upload extends BaseCommand {
|
||||
});
|
||||
|
||||
let skipUpload = false;
|
||||
|
||||
let skipAsset = false;
|
||||
let existingAssetId: string | undefined = undefined;
|
||||
|
||||
if (!options.skipHash) {
|
||||
const assetBulkUploadCheckDto = { assets: [{ id: asset.path, checksum: await asset.hash() }] };
|
||||
|
||||
@@ -83,14 +92,24 @@ export default class Upload extends BaseCommand {
|
||||
});
|
||||
|
||||
skipUpload = checkResponse.data.results[0].action === 'reject';
|
||||
|
||||
const isDuplicate = checkResponse.data.results[0].reason === 'duplicate';
|
||||
if (isDuplicate) {
|
||||
existingAssetId = checkResponse.data.results[0].assetId;
|
||||
}
|
||||
|
||||
skipAsset = skipUpload && !isDuplicate;
|
||||
}
|
||||
|
||||
if (!skipUpload) {
|
||||
if (!skipAsset) {
|
||||
if (!options.dryRun) {
|
||||
const formData = asset.getUploadFormData();
|
||||
const res = await this.uploadAsset(formData);
|
||||
if (!skipUpload) {
|
||||
const formData = asset.getUploadFormData();
|
||||
const res = await this.uploadAsset(formData);
|
||||
existingAssetId = res.data.id;
|
||||
}
|
||||
|
||||
if (options.album && asset.albumName) {
|
||||
if ((options.album || options.albumName) && asset.albumName !== undefined) {
|
||||
let album = existingAlbums.find((album) => album.albumName === asset.albumName);
|
||||
if (!album) {
|
||||
const res = await this.immichApi.albumApi.createAlbum({
|
||||
@@ -100,7 +119,12 @@ export default class Upload extends BaseCommand {
|
||||
existingAlbums.push(album);
|
||||
}
|
||||
|
||||
await this.immichApi.albumApi.addAssetsToAlbum({ id: album.id, bulkIdsDto: { ids: [res.data.id] } });
|
||||
if (existingAssetId) {
|
||||
await this.immichApi.albumApi.addAssetsToAlbum({
|
||||
id: album.id,
|
||||
bulkIdsDto: { ids: [existingAssetId] },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,4 +5,6 @@ export class UploadOptionsDto {
|
||||
skipHash? = false;
|
||||
delete? = false;
|
||||
album? = false;
|
||||
albumName? = '';
|
||||
includeHidden? = false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import * as fs from 'fs';
|
||||
import * as fs from 'graceful-fs';
|
||||
import { basename } from 'node:path';
|
||||
import crypto from 'crypto';
|
||||
import Os from 'os';
|
||||
|
||||
@@ -26,11 +26,17 @@ program
|
||||
.addOption(new Option('-r, --recursive', 'Recursive').env('IMMICH_RECURSIVE').default(false))
|
||||
.addOption(new Option('-i, --ignore [paths...]', 'Paths to ignore').env('IMMICH_IGNORE_PATHS'))
|
||||
.addOption(new Option('-h, --skip-hash', "Don't hash files before upload").env('IMMICH_SKIP_HASH').default(false))
|
||||
.addOption(new Option('-i, --include-hidden', 'Include hidden folders').env('IMMICH_INCLUDE_HIDDEN').default(false))
|
||||
.addOption(
|
||||
new Option('-a, --album', 'Automatically create albums based on folder name')
|
||||
.env('IMMICH_AUTO_CREATE_ALBUM')
|
||||
.default(false),
|
||||
)
|
||||
.addOption(
|
||||
new Option('-A, --album-name <name>', 'Add all assets to specified album')
|
||||
.env('IMMICH_ALBUM_NAME')
|
||||
.conflicts('album'),
|
||||
)
|
||||
.addOption(
|
||||
new Option('-n, --dry-run', "Don't perform any actions, just show what will be done")
|
||||
.env('IMMICH_DRY_RUN')
|
||||
|
||||
@@ -19,7 +19,7 @@ const tests: Test[] = [
|
||||
files: {},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single path',
|
||||
test: 'should crawl a single folder',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
},
|
||||
@@ -27,6 +27,25 @@ const tests: Test[] = [
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single file',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/image.jpg'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should crawl a single file and a folder',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/image.jpg', '/images/'],
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
'/images/image2.jpg': true,
|
||||
},
|
||||
},
|
||||
{
|
||||
test: 'should exclude by file extension',
|
||||
options: {
|
||||
@@ -54,6 +73,7 @@ const tests: Test[] = [
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
exclusionPatterns: ['**/raw/**'],
|
||||
recursive: true,
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
@@ -98,6 +118,7 @@ const tests: Test[] = [
|
||||
test: 'should crawl a single path',
|
||||
options: {
|
||||
pathsToCrawl: ['/photos/'],
|
||||
recursive: true,
|
||||
},
|
||||
files: {
|
||||
'/photos/image.jpg': true,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CrawlOptionsDto } from 'src/cores/dto/crawl-options-dto';
|
||||
import { glob } from 'glob';
|
||||
import * as fs from 'fs';
|
||||
|
||||
export class CrawlService {
|
||||
private readonly extensions!: string[];
|
||||
@@ -8,21 +9,57 @@ export class CrawlService {
|
||||
this.extensions = image.concat(video).map((extension) => extension.replace('.', ''));
|
||||
}
|
||||
|
||||
crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
|
||||
async crawl(crawlOptions: CrawlOptionsDto): Promise<string[]> {
|
||||
const { pathsToCrawl, exclusionPatterns, includeHidden } = crawlOptions;
|
||||
if (!pathsToCrawl) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
const base = pathsToCrawl.length === 1 ? pathsToCrawl[0] : `{${pathsToCrawl.join(',')}}`;
|
||||
const extensions = `*{${this.extensions}}`;
|
||||
const patterns: string[] = [];
|
||||
const crawledFiles: string[] = [];
|
||||
|
||||
return glob(`${base}/**/${extensions}`, {
|
||||
for await (const currentPath of pathsToCrawl) {
|
||||
try {
|
||||
const stats = await fs.promises.stat(currentPath);
|
||||
if (stats.isFile() || stats.isSymbolicLink()) {
|
||||
crawledFiles.push(currentPath);
|
||||
} else {
|
||||
patterns.push(currentPath);
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.code === 'ENOENT') {
|
||||
patterns.push(currentPath);
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let searchPattern: string;
|
||||
if (patterns.length === 1) {
|
||||
searchPattern = patterns[0];
|
||||
} else if (patterns.length === 0) {
|
||||
return crawledFiles;
|
||||
} else {
|
||||
searchPattern = '{' + patterns.join(',') + '}';
|
||||
}
|
||||
|
||||
if (crawlOptions.recursive) {
|
||||
searchPattern = searchPattern + '/**/';
|
||||
}
|
||||
|
||||
searchPattern = `${searchPattern}/*.{${this.extensions.join(',')}}`;
|
||||
|
||||
const globbedFiles = await glob(searchPattern, {
|
||||
absolute: true,
|
||||
nocase: true,
|
||||
nodir: true,
|
||||
dot: includeHidden,
|
||||
ignore: exclusionPatterns,
|
||||
});
|
||||
|
||||
const returnedFiles = crawledFiles.concat(globbedFiles);
|
||||
returnedFiles.sort();
|
||||
return returnedFiles;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ describe(`upload (e2e)`, () => {
|
||||
expect(assets.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('should not create a new album', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], { recursive: true });
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should create album from folder name', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
@@ -46,4 +52,33 @@ describe(`upload (e2e)`, () => {
|
||||
const natureAlbum = albums[0];
|
||||
expect(natureAlbum.albumName).toEqual('nature');
|
||||
});
|
||||
|
||||
it('should add existing assets to album', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
// Upload again, but this time add to album
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
album: true,
|
||||
});
|
||||
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(1);
|
||||
const natureAlbum = albums[0];
|
||||
expect(natureAlbum.albumName).toEqual('nature');
|
||||
});
|
||||
|
||||
it('should upload to the specified album name', async () => {
|
||||
await new Upload(CLI_BASE_OPTIONS).run([`${IMMICH_TEST_ASSET_PATH}/albums/nature/`], {
|
||||
recursive: true,
|
||||
albumName: 'testAlbum',
|
||||
});
|
||||
|
||||
const albums = await api.albumApi.getAllAlbums(server, admin.accessToken);
|
||||
expect(albums.length).toEqual(1);
|
||||
const testAlbum = albums[0];
|
||||
expect(testAlbum.albumName).toEqual('testAlbum');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ x-server-build: &server-common
|
||||
context: ../
|
||||
dockerfile: server/Dockerfile
|
||||
target: dev
|
||||
restart: always
|
||||
volumes:
|
||||
- ../server:/usr/src/app
|
||||
- ${UPLOAD_LOCATION}/photos:/usr/src/app/upload
|
||||
@@ -19,8 +20,6 @@ x-server-build: &server-common
|
||||
- /etc/localtime:/etc/localtime:ro
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
ulimits:
|
||||
nofile:
|
||||
soft: 1048576
|
||||
@@ -87,8 +86,6 @@ services:
|
||||
- model-cache:/cache
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
depends_on:
|
||||
- database
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -11,7 +11,6 @@ services:
|
||||
# volumes:
|
||||
# - /usr/lib/wsl:/usr/lib/wsl # If using VAAPI in WSL2
|
||||
# environment:
|
||||
# - NVIDIA_DRIVER_CAPABILITIES=all # If using NVIDIA GPU
|
||||
# - LD_LIBRARY_PATH=/usr/lib/wsl/lib # If using VAAPI in WSL2
|
||||
# - LIBVA_DRIVER_NAME=d3d12 # If using VAAPI in WSL2
|
||||
# deploy: # Uncomment this section if using NVIDIA GPU
|
||||
|
||||
@@ -56,10 +56,6 @@ Template changes will only apply to new assets. To retroactively apply the templ
|
||||
|
||||
This is fixed by running the storage migration job.
|
||||
|
||||
### Why is object detection not very good?
|
||||
|
||||
The default image tagging model is relatively small. You can change this for a larger model like `google/vit-base-patch16-224` by setting the model name under Settings > Machine Learning Settings > Image Tagging. You can then re-run the Image Tagging job to get improved tags.
|
||||
|
||||
### Why are there so many thumbnail generation jobs?
|
||||
|
||||
Immich generates three thumbnails for each asset (blurred, small, and large), as well as a thumbnail for each recognized face.
|
||||
|
||||
@@ -73,7 +73,7 @@ The Immich Microservices image uses the same `Dockerfile` as the Immich Server,
|
||||
- Thumbnail Generation
|
||||
- Metadata Extraction
|
||||
- Video Transcoding
|
||||
- Object Tagging
|
||||
- Smart Search
|
||||
- Facial Recognition
|
||||
- Storage Template Migration
|
||||
- Sidecar (see [XMP Sidecars](/docs/features/xmp-sidecars.md))
|
||||
|
||||
@@ -38,9 +38,6 @@ The default configuration looks like this:
|
||||
"metadataExtraction": {
|
||||
"concurrency": 5
|
||||
},
|
||||
"objectTagging": {
|
||||
"concurrency": 2
|
||||
},
|
||||
"recognizeFaces": {
|
||||
"concurrency": 2
|
||||
},
|
||||
@@ -73,11 +70,6 @@ The default configuration looks like this:
|
||||
"machineLearning": {
|
||||
"enabled": true,
|
||||
"url": "http://immich-machine-learning:3003",
|
||||
"classification": {
|
||||
"enabled": false,
|
||||
"modelName": "microsoft/resnet-50",
|
||||
"minScore": 0.9
|
||||
},
|
||||
"clip": {
|
||||
"enabled": true,
|
||||
"modelName": "ViT-B-32__openai"
|
||||
|
||||
@@ -30,6 +30,8 @@ download:
|
||||
locale_code: pl-PL
|
||||
- file: mobile/assets/i18n/fi-FI.json
|
||||
locale_code: fi-FI
|
||||
- file: mobile/assets/i18n/pt-PT.json
|
||||
locale_code: pt-PT
|
||||
- file: mobile/assets/i18n/pt-BR.json
|
||||
locale_code: pt-BR
|
||||
- file: mobile/assets/i18n/cs-CZ.json
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
# Immich Machine Learning
|
||||
|
||||
- Image classification
|
||||
- CLIP embeddings
|
||||
- Facial recognition
|
||||
|
||||
|
||||
@@ -59,3 +59,37 @@ def clip_preprocess_cfg() -> dict[str, Any]:
|
||||
"resize_mode": "shortest",
|
||||
"fill_color": 0,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def clip_tokenizer_cfg() -> dict[str, Any]:
|
||||
return {
|
||||
"add_prefix_space": False,
|
||||
"added_tokens_decoder": {
|
||||
"49406": {
|
||||
"content": "<|startoftext|>",
|
||||
"lstrip": False,
|
||||
"normalized": True,
|
||||
"rstrip": False,
|
||||
"single_word": False,
|
||||
"special": True,
|
||||
},
|
||||
"49407": {
|
||||
"content": "<|endoftext|>",
|
||||
"lstrip": False,
|
||||
"normalized": True,
|
||||
"rstrip": False,
|
||||
"single_word": False,
|
||||
"special": True,
|
||||
},
|
||||
},
|
||||
"bos_token": "<|startoftext|>",
|
||||
"clean_up_tokenization_spaces": True,
|
||||
"do_lower_case": True,
|
||||
"eos_token": "<|endoftext|>",
|
||||
"errors": "replace",
|
||||
"model_max_length": 77,
|
||||
"pad_token": "<|endoftext|>",
|
||||
"tokenizer_class": "CLIPTokenizer",
|
||||
"unk_token": "<|endoftext|>",
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ from .base import InferenceModel
|
||||
from .clip import MCLIPEncoder, OpenCLIPEncoder
|
||||
from .constants import is_insightface, is_mclip, is_openclip
|
||||
from .facial_recognition import FaceRecognizer
|
||||
from .image_classification import ImageClassifier
|
||||
|
||||
|
||||
def from_model_type(model_type: ModelType, model_name: str, **model_kwargs: Any) -> InferenceModel:
|
||||
@@ -19,8 +18,6 @@ def from_model_type(model_type: ModelType, model_name: str, **model_kwargs: Any)
|
||||
case ModelType.FACIAL_RECOGNITION:
|
||||
if is_insightface(model_name):
|
||||
return FaceRecognizer(model_name, **model_kwargs)
|
||||
case ModelType.IMAGE_CLASSIFICATION:
|
||||
return ImageClassifier(model_name, **model_kwargs)
|
||||
case _:
|
||||
raise ValueError(f"Unknown model type {model_type}")
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ class InferenceModel(ABC):
|
||||
)
|
||||
log.debug(
|
||||
(
|
||||
f"Setting '{self.model_name}' execution providers to {self.providers}"
|
||||
f"Setting '{self.model_name}' execution providers to {self.providers} "
|
||||
"in descending order of preference"
|
||||
),
|
||||
)
|
||||
@@ -55,7 +55,7 @@ class InferenceModel(ABC):
|
||||
def download(self) -> None:
|
||||
if not self.cached:
|
||||
log.info(
|
||||
(f"Downloading {self.model_type.replace('-', ' ')} model '{self.model_name}'." "This may take a while.")
|
||||
f"Downloading {self.model_type.replace('-', ' ')} model '{self.model_name}'. This may take a while."
|
||||
)
|
||||
self._download()
|
||||
|
||||
@@ -63,7 +63,7 @@ class InferenceModel(ABC):
|
||||
if self.loaded:
|
||||
return
|
||||
self.download()
|
||||
log.info(f"Loading {self.model_type.replace('-', ' ')} model '{self.model_name}'")
|
||||
log.info(f"Loading {self.model_type.replace('-', ' ')} model '{self.model_name}' to memory")
|
||||
self._load()
|
||||
self.loaded = True
|
||||
|
||||
@@ -119,11 +119,11 @@ class InferenceModel(ABC):
|
||||
def clear_cache(self) -> None:
|
||||
if not self.cache_dir.exists():
|
||||
log.warn(
|
||||
f"Attempted to clear cache for model '{self.model_name}' but cache directory does not exist.",
|
||||
f"Attempted to clear cache for model '{self.model_name}', but cache directory does not exist",
|
||||
)
|
||||
return
|
||||
if not rmtree.avoids_symlink_attacks:
|
||||
raise RuntimeError("Attempted to clear cache, but rmtree is not safe on this platform.")
|
||||
raise RuntimeError("Attempted to clear cache, but rmtree is not safe on this platform")
|
||||
|
||||
if self.cache_dir.is_dir():
|
||||
log.info(f"Cleared cache directory for model '{self.model_name}'.")
|
||||
|
||||
@@ -8,11 +8,11 @@ from typing import Any, Literal
|
||||
import numpy as np
|
||||
import onnxruntime as ort
|
||||
from PIL import Image
|
||||
from transformers import AutoTokenizer
|
||||
from tokenizers import Encoding, Tokenizer
|
||||
|
||||
from app.config import clean_name, log
|
||||
from app.models.transforms import crop, get_pil_resampling, normalize, resize, to_numpy
|
||||
from app.schemas import ModelType, ndarray_f32, ndarray_i32, ndarray_i64
|
||||
from app.schemas import ModelType, ndarray_f32, ndarray_i32
|
||||
|
||||
from .base import InferenceModel
|
||||
|
||||
@@ -40,6 +40,7 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
log.debug(f"Loaded clip text model '{self.model_name}'")
|
||||
|
||||
if self.mode == "vision" or self.mode is None:
|
||||
log.debug(f"Loading clip vision model '{self.model_name}'")
|
||||
@@ -50,6 +51,7 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
providers=self.providers,
|
||||
provider_options=self.provider_options,
|
||||
)
|
||||
log.debug(f"Loaded clip vision model '{self.model_name}'")
|
||||
|
||||
def _predict(self, image_or_text: Image.Image | str) -> ndarray_f32:
|
||||
if isinstance(image_or_text, bytes):
|
||||
@@ -99,6 +101,14 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
def visual_path(self) -> Path:
|
||||
return self.visual_dir / "model.onnx"
|
||||
|
||||
@property
|
||||
def tokenizer_file_path(self) -> Path:
|
||||
return self.textual_dir / "tokenizer.json"
|
||||
|
||||
@property
|
||||
def tokenizer_cfg_path(self) -> Path:
|
||||
return self.textual_dir / "tokenizer_config.json"
|
||||
|
||||
@property
|
||||
def preprocess_cfg_path(self) -> Path:
|
||||
return self.visual_dir / "preprocess_cfg.json"
|
||||
@@ -107,6 +117,34 @@ class BaseCLIPEncoder(InferenceModel):
|
||||
def cached(self) -> bool:
|
||||
return self.textual_path.is_file() and self.visual_path.is_file()
|
||||
|
||||
@cached_property
|
||||
def model_cfg(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading model config for CLIP model '{self.model_name}'")
|
||||
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
|
||||
log.debug(f"Loaded model config for CLIP model '{self.model_name}'")
|
||||
return model_cfg
|
||||
|
||||
@cached_property
|
||||
def tokenizer_file(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading tokenizer file for CLIP model '{self.model_name}'")
|
||||
tokenizer_file: dict[str, Any] = json.load(self.tokenizer_file_path.open())
|
||||
log.debug(f"Loaded tokenizer file for CLIP model '{self.model_name}'")
|
||||
return tokenizer_file
|
||||
|
||||
@cached_property
|
||||
def tokenizer_cfg(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading tokenizer config for CLIP model '{self.model_name}'")
|
||||
tokenizer_cfg: dict[str, Any] = json.load(self.tokenizer_cfg_path.open())
|
||||
log.debug(f"Loaded tokenizer config for CLIP model '{self.model_name}'")
|
||||
return tokenizer_cfg
|
||||
|
||||
@cached_property
|
||||
def preprocess_cfg(self) -> dict[str, Any]:
|
||||
log.debug(f"Loading visual preprocessing config for CLIP model '{self.model_name}'")
|
||||
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open())
|
||||
log.debug(f"Loaded visual preprocessing config for CLIP model '{self.model_name}'")
|
||||
return preprocess_cfg
|
||||
|
||||
|
||||
class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
def __init__(
|
||||
@@ -121,8 +159,8 @@ class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
def _load(self) -> None:
|
||||
super()._load()
|
||||
|
||||
self.tokenizer = AutoTokenizer.from_pretrained(self.textual_dir)
|
||||
self.sequence_length = self.model_cfg["text_cfg"]["context_length"]
|
||||
context_length = self.model_cfg["text_cfg"]["context_length"]
|
||||
pad_token = self.tokenizer_cfg["pad_token"]
|
||||
|
||||
self.size = (
|
||||
self.preprocess_cfg["size"][0] if type(self.preprocess_cfg["size"]) == list else self.preprocess_cfg["size"]
|
||||
@@ -131,16 +169,16 @@ class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
self.mean = np.array(self.preprocess_cfg["mean"], dtype=np.float32)
|
||||
self.std = np.array(self.preprocess_cfg["std"], dtype=np.float32)
|
||||
|
||||
log.debug(f"Loading tokenizer for CLIP model '{self.model_name}'")
|
||||
self.tokenizer: Tokenizer = Tokenizer.from_file(self.tokenizer_file_path.as_posix())
|
||||
pad_id = self.tokenizer.token_to_id(pad_token)
|
||||
self.tokenizer.enable_padding(length=context_length, pad_token=pad_token, pad_id=pad_id)
|
||||
self.tokenizer.enable_truncation(max_length=context_length)
|
||||
log.debug(f"Loaded tokenizer for CLIP model '{self.model_name}'")
|
||||
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
input_ids: ndarray_i64 = self.tokenizer(
|
||||
text,
|
||||
max_length=self.sequence_length,
|
||||
return_tensors="np",
|
||||
return_attention_mask=False,
|
||||
padding="max_length",
|
||||
truncation=True,
|
||||
).input_ids
|
||||
return {"text": input_ids.astype(np.int32)}
|
||||
tokens: Encoding = self.tokenizer.encode(text)
|
||||
return {"text": np.array([tokens.ids], dtype=np.int32)}
|
||||
|
||||
def transform(self, image: Image.Image) -> dict[str, ndarray_f32]:
|
||||
image = resize(image, self.size)
|
||||
@@ -149,18 +187,11 @@ class OpenCLIPEncoder(BaseCLIPEncoder):
|
||||
image_np = normalize(image_np, self.mean, self.std)
|
||||
return {"image": np.expand_dims(image_np.transpose(2, 0, 1), 0)}
|
||||
|
||||
@cached_property
|
||||
def model_cfg(self) -> dict[str, Any]:
|
||||
model_cfg: dict[str, Any] = json.load(self.model_cfg_path.open())
|
||||
return model_cfg
|
||||
|
||||
@cached_property
|
||||
def preprocess_cfg(self) -> dict[str, Any]:
|
||||
preprocess_cfg: dict[str, Any] = json.load(self.preprocess_cfg_path.open())
|
||||
return preprocess_cfg
|
||||
|
||||
|
||||
class MCLIPEncoder(OpenCLIPEncoder):
|
||||
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
|
||||
tokens: dict[str, ndarray_i64] = self.tokenizer(text, return_tensors="np")
|
||||
return {k: v.astype(np.int32) for k, v in tokens.items()}
|
||||
tokens: Encoding = self.tokenizer.encode(text)
|
||||
return {
|
||||
"input_ids": np.array([tokens.ids], dtype=np.int32),
|
||||
"attention_mask": np.array([tokens.attention_mask], dtype=np.int32),
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from huggingface_hub import snapshot_download
|
||||
from optimum.onnxruntime import ORTModelForImageClassification
|
||||
from optimum.pipelines import pipeline
|
||||
from PIL import Image
|
||||
from transformers import AutoImageProcessor
|
||||
|
||||
from ..config import log
|
||||
from ..schemas import ModelType
|
||||
from .base import InferenceModel
|
||||
|
||||
|
||||
class ImageClassifier(InferenceModel):
|
||||
_model_type = ModelType.IMAGE_CLASSIFICATION
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str,
|
||||
min_score: float = 0.9,
|
||||
cache_dir: Path | str | None = None,
|
||||
**model_kwargs: Any,
|
||||
) -> None:
|
||||
self.min_score = model_kwargs.pop("minScore", min_score)
|
||||
super().__init__(model_name, cache_dir, **model_kwargs)
|
||||
|
||||
def _download(self) -> None:
|
||||
snapshot_download(
|
||||
cache_dir=self.cache_dir,
|
||||
repo_id=self.model_name,
|
||||
allow_patterns=["*.bin", "*.json", "*.txt"],
|
||||
local_dir=self.cache_dir,
|
||||
local_dir_use_symlinks=True,
|
||||
)
|
||||
|
||||
def _load(self) -> None:
|
||||
processor = AutoImageProcessor.from_pretrained(self.cache_dir, cache_dir=self.cache_dir)
|
||||
model_path = self.cache_dir / "model.onnx"
|
||||
model_kwargs = {
|
||||
"cache_dir": self.cache_dir,
|
||||
"provider": self.providers[0],
|
||||
"provider_options": self.provider_options[0],
|
||||
"session_options": self.sess_options,
|
||||
}
|
||||
|
||||
if model_path.exists():
|
||||
model = ORTModelForImageClassification.from_pretrained(self.cache_dir, **model_kwargs)
|
||||
self.model = pipeline(self.model_type.value, model, feature_extractor=processor)
|
||||
else:
|
||||
log.info(
|
||||
(
|
||||
f"ONNX model not found in cache directory for '{self.model_name}'."
|
||||
"Exporting optimized model for future use."
|
||||
),
|
||||
)
|
||||
self.sess_options.optimized_model_filepath = model_path.as_posix()
|
||||
self.model = pipeline(
|
||||
self.model_type.value,
|
||||
self.model_name,
|
||||
model_kwargs=model_kwargs,
|
||||
feature_extractor=processor,
|
||||
)
|
||||
|
||||
def _predict(self, image: Image.Image | bytes) -> list[str]:
|
||||
if isinstance(image, bytes):
|
||||
image = Image.open(BytesIO(image))
|
||||
predictions: list[dict[str, Any]] = self.model(image)
|
||||
tags = [tag for pred in predictions for tag in pred["label"].split(", ") if pred["score"] >= self.min_score]
|
||||
|
||||
return tags
|
||||
|
||||
def configure(self, **model_kwargs: Any) -> None:
|
||||
self.min_score = model_kwargs.pop("minScore", self.min_score)
|
||||
@@ -25,7 +25,6 @@ class BoundingBox(TypedDict):
|
||||
|
||||
|
||||
class ModelType(StrEnum):
|
||||
IMAGE_CLASSIFICATION = "image-classification"
|
||||
CLIP = "clip"
|
||||
FACIAL_RECOGNITION = "facial-recognition"
|
||||
|
||||
|
||||
@@ -17,42 +17,9 @@ from .models.base import PicklableSessionOptions
|
||||
from .models.cache import ModelCache
|
||||
from .models.clip import OpenCLIPEncoder
|
||||
from .models.facial_recognition import FaceRecognizer
|
||||
from .models.image_classification import ImageClassifier
|
||||
from .schemas import ModelType
|
||||
|
||||
|
||||
class TestImageClassifier:
|
||||
classifier_preds = [
|
||||
{"label": "that's an image alright", "score": 0.8},
|
||||
{"label": "well it ends with .jpg", "score": 0.1},
|
||||
{"label": "idk, im just seeing bytes", "score": 0.05},
|
||||
{"label": "not sure", "score": 0.04},
|
||||
{"label": "probably a virus", "score": 0.01},
|
||||
]
|
||||
|
||||
def test_min_score(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
|
||||
mocker.patch.object(ImageClassifier, "load")
|
||||
classifier = ImageClassifier("test_model_name", min_score=0.0)
|
||||
assert classifier.min_score == 0.0
|
||||
|
||||
classifier.model = mock.Mock()
|
||||
classifier.model.return_value = self.classifier_preds
|
||||
|
||||
all_labels = classifier.predict(pil_image)
|
||||
classifier.min_score = 0.5
|
||||
filtered_labels = classifier.predict(pil_image)
|
||||
|
||||
assert all_labels == [
|
||||
"that's an image alright",
|
||||
"well it ends with .jpg",
|
||||
"idk",
|
||||
"im just seeing bytes",
|
||||
"not sure",
|
||||
"probably a virus",
|
||||
]
|
||||
assert filtered_labels == ["that's an image alright"]
|
||||
|
||||
|
||||
class TestCLIP:
|
||||
embedding = np.random.rand(512).astype(np.float32)
|
||||
cache_dir = Path("test_cache")
|
||||
@@ -63,11 +30,13 @@ class TestCLIP:
|
||||
mocker: MockerFixture,
|
||||
clip_model_cfg: dict[str, Any],
|
||||
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
|
||||
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
||||
) -> None:
|
||||
mocker.patch.object(OpenCLIPEncoder, "download")
|
||||
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
|
||||
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
|
||||
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
|
||||
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
||||
mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True)
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
|
||||
@@ -85,11 +54,13 @@ class TestCLIP:
|
||||
mocker: MockerFixture,
|
||||
clip_model_cfg: dict[str, Any],
|
||||
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
|
||||
clip_tokenizer_cfg: Callable[[Path], dict[str, Any]],
|
||||
) -> None:
|
||||
mocker.patch.object(OpenCLIPEncoder, "download")
|
||||
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
|
||||
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
|
||||
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
|
||||
mocker.patch.object(OpenCLIPEncoder, "tokenizer_cfg", clip_tokenizer_cfg)
|
||||
mocker.patch("app.models.clip.Tokenizer.from_file", autospec=True)
|
||||
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
|
||||
mocked.return_value.run.return_value = [[self.embedding]]
|
||||
|
||||
@@ -145,17 +116,15 @@ class TestFaceRecognition:
|
||||
class TestCache:
|
||||
async def test_caches(self, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache()
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
assert len(model_cache.cache._cache) == 1
|
||||
mock_get_model.assert_called_once()
|
||||
|
||||
async def test_kwargs_used(self, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache()
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION, cache_dir="test_cache")
|
||||
mock_get_model.assert_called_once_with(
|
||||
ModelType.IMAGE_CLASSIFICATION, "test_model_name", cache_dir="test_cache"
|
||||
)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION, cache_dir="test_cache")
|
||||
mock_get_model.assert_called_once_with(ModelType.FACIAL_RECOGNITION, "test_model_name", cache_dir="test_cache")
|
||||
|
||||
async def test_different_clip(self, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache()
|
||||
@@ -172,14 +141,14 @@ class TestCache:
|
||||
@mock.patch("app.models.cache.OptimisticLock", autospec=True)
|
||||
async def test_model_ttl(self, mock_lock_cls: mock.Mock, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache(ttl=100)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
mock_lock_cls.return_value.__aenter__.return_value.cas.assert_called_with(mock.ANY, ttl=100)
|
||||
|
||||
@mock.patch("app.models.cache.SimpleMemoryCache.expire")
|
||||
async def test_revalidate(self, mock_cache_expire: mock.Mock, mock_get_model: mock.Mock) -> None:
|
||||
model_cache = ModelCache(ttl=100, revalidate=True)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.IMAGE_CLASSIFICATION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
await model_cache.get("test_model_name", ModelType.FACIAL_RECOGNITION)
|
||||
mock_cache_expire.assert_called_once_with(mock.ANY, 100)
|
||||
|
||||
|
||||
@@ -188,23 +157,6 @@ class TestCache:
|
||||
reason="More time-consuming since it deploys the app and loads models.",
|
||||
)
|
||||
class TestEndpoints:
|
||||
def test_tagging_endpoint(
|
||||
self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient
|
||||
) -> None:
|
||||
byte_image = BytesIO()
|
||||
pil_image.save(byte_image, format="jpeg")
|
||||
response = deployed_app.post(
|
||||
"http://localhost:3003/predict",
|
||||
data={
|
||||
"modelName": "microsoft/resnet-50",
|
||||
"modelType": "image-classification",
|
||||
"options": json.dumps({"minScore": 0.0}),
|
||||
},
|
||||
files={"image": byte_image.getvalue()},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == responses["image-classification"]
|
||||
|
||||
def test_clip_image_endpoint(
|
||||
self, pil_image: Image.Image, responses: dict[str, Any], deployed_app: TestClient
|
||||
) -> None:
|
||||
|
||||
@@ -12,7 +12,6 @@ byte_image = BytesIO()
|
||||
|
||||
@events.init_command_line_parser.add_listener
|
||||
def _(parser: ArgumentParser) -> None:
|
||||
parser.add_argument("--tag-model", type=str, default="microsoft/resnet-50")
|
||||
parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai")
|
||||
parser.add_argument("--face-model", type=str, default="buffalo_l")
|
||||
parser.add_argument(
|
||||
@@ -54,18 +53,6 @@ class InferenceLoadTest(HttpUser):
|
||||
self.data = byte_image.getvalue()
|
||||
|
||||
|
||||
class ClassificationFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def classify(self) -> None:
|
||||
data = [
|
||||
("modelName", self.environment.parsed_options.clip_model),
|
||||
("modelType", "clip"),
|
||||
("options", json.dumps({"minScore": self.environment.parsed_options.tag_min_score})),
|
||||
]
|
||||
files = {"image": self.data}
|
||||
self.client.post("/predict", data=data, files=files)
|
||||
|
||||
|
||||
class CLIPTextFormDataLoadTest(InferenceLoadTest):
|
||||
@task
|
||||
def encode_text(self) -> None:
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
"handlers": {
|
||||
"console": {
|
||||
"class": "app.config.CustomRichHandler",
|
||||
"formatter": "rich",
|
||||
"level": "INFO"
|
||||
"formatter": "rich"
|
||||
}
|
||||
},
|
||||
"loggers": {
|
||||
|
||||
2980
machine-learning/poetry.lock
generated
2980
machine-learning/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -7,44 +7,34 @@ readme = "README.md"
|
||||
packages = [{include = "app"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "~3.11"
|
||||
torch = [
|
||||
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.1.0", source = "pypi"},
|
||||
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.1.0", source = "pytorch-cpu"}
|
||||
]
|
||||
transformers = "^4.29.2"
|
||||
python = ">=3.10,<3.12"
|
||||
onnxruntime = "^1.15.0"
|
||||
insightface = "^0.7.3"
|
||||
opencv-python-headless = "^4.7.0.72"
|
||||
pillow = "^9.5.0"
|
||||
fastapi = "^0.95.2"
|
||||
uvicorn = {extras = ["standard"], version = "^0.22.0"}
|
||||
insightface = ">=0.7.3,<1.0"
|
||||
opencv-python-headless = ">=4.7.0.72,<5.0"
|
||||
pillow = ">=9.5.0,<11.0"
|
||||
fastapi = ">=0.95.2,<1.0"
|
||||
uvicorn = {extras = ["standard"], version = ">=0.22.0,<1.0"}
|
||||
pydantic = "^1.10.8"
|
||||
aiocache = "^0.12.1"
|
||||
optimum = "^1.9.1"
|
||||
rich = "^13.4.2"
|
||||
ftfy = "^6.1.1"
|
||||
aiocache = ">=0.12.1,<1.0"
|
||||
rich = ">=13.4.2"
|
||||
ftfy = ">=6.1.1"
|
||||
setuptools = "^68.0.0"
|
||||
python-multipart = "^0.0.6"
|
||||
orjson = "^3.9.5"
|
||||
safetensors = "0.3.2"
|
||||
gunicorn = "^21.1.0"
|
||||
python-multipart = ">=0.0.6,<1.0"
|
||||
orjson = ">=3.9.5"
|
||||
gunicorn = ">=21.1.0"
|
||||
huggingface-hub = ">=0.20.1,<1.0"
|
||||
tokenizers = ">=0.15.0,<1.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
mypy = "^1.3.0"
|
||||
black = "^23.3.0"
|
||||
pytest = "^7.3.1"
|
||||
locust = "^2.15.1"
|
||||
httpx = "^0.24.1"
|
||||
pytest-asyncio = "^0.21.0"
|
||||
pytest-cov = "^4.1.0"
|
||||
ruff = "^0.0.272"
|
||||
pytest-mock = "^3.11.1"
|
||||
|
||||
[[tool.poetry.source]]
|
||||
name = "pytorch-cpu"
|
||||
url = "https://download.pytorch.org/whl/cpu"
|
||||
priority = "explicit"
|
||||
mypy = ">=1.3.0"
|
||||
black = ">=23.3.0"
|
||||
pytest = ">=7.3.1"
|
||||
locust = ">=2.15.1"
|
||||
httpx = ">=0.24.1"
|
||||
pytest-asyncio = ">=0.21.0"
|
||||
pytest-cov = ">=4.1.0"
|
||||
ruff = ">=0.0.272"
|
||||
pytest-mock = ">=3.11.1"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
|
||||
@@ -14,7 +14,7 @@ const List<Locale> locales = [
|
||||
Locale('ja', 'JP'),
|
||||
Locale('pl', 'PL'),
|
||||
Locale('fi', 'FI'),
|
||||
Locale('pt', 'PR'),
|
||||
Locale('pt', 'PT'),
|
||||
Locale('cs', 'CZ'),
|
||||
Locale('uk', 'UA'),
|
||||
Locale('ru', 'RU'),
|
||||
|
||||
@@ -795,8 +795,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
imageProvider: provider,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: isFromDto
|
||||
? '${a.remoteId}-$heroOffset'
|
||||
: a.id + heroOffset,
|
||||
? '${currentAsset.remoteId}-$heroOffset'
|
||||
: currentAsset.id + heroOffset,
|
||||
transitionOnUserGestures: true,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
@@ -815,8 +815,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
||||
handleSwipeUpDown(details),
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: isFromDto
|
||||
? '${a.remoteId}-$heroOffset'
|
||||
: a.id + heroOffset,
|
||||
? '${currentAsset.remoteId}-$heroOffset'
|
||||
: currentAsset.id + heroOffset,
|
||||
),
|
||||
filterQuality: FilterQuality.high,
|
||||
maxScale: 1.0,
|
||||
|
||||
9
mobile/openapi/.openapi-generator/FILES
generated
9
mobile/openapi/.openapi-generator/FILES
generated
@@ -50,7 +50,6 @@ doc/CQMode.md
|
||||
doc/ChangePasswordDto.md
|
||||
doc/CheckExistingAssetsDto.md
|
||||
doc/CheckExistingAssetsResponseDto.md
|
||||
doc/ClassificationConfig.md
|
||||
doc/Colorspace.md
|
||||
doc/CreateAlbumDto.md
|
||||
doc/CreateLibraryDto.md
|
||||
@@ -90,6 +89,7 @@ doc/MapMarkerResponseDto.md
|
||||
doc/MapTheme.md
|
||||
doc/MemoryLaneResponseDto.md
|
||||
doc/MergePersonDto.md
|
||||
doc/MetricsApi.md
|
||||
doc/ModelType.md
|
||||
doc/OAuthApi.md
|
||||
doc/OAuthAuthorizeResponseDto.md
|
||||
@@ -146,6 +146,7 @@ doc/SystemConfigLibraryScanDto.md
|
||||
doc/SystemConfigLoggingDto.md
|
||||
doc/SystemConfigMachineLearningDto.md
|
||||
doc/SystemConfigMapDto.md
|
||||
doc/SystemConfigMetricsDto.md
|
||||
doc/SystemConfigNewVersionCheckDto.md
|
||||
doc/SystemConfigOAuthDto.md
|
||||
doc/SystemConfigPasswordLoginDto.md
|
||||
@@ -189,6 +190,7 @@ lib/api/authentication_api.dart
|
||||
lib/api/face_api.dart
|
||||
lib/api/job_api.dart
|
||||
lib/api/library_api.dart
|
||||
lib/api/metrics_api.dart
|
||||
lib/api/o_auth_api.dart
|
||||
lib/api/partner_api.dart
|
||||
lib/api/person_api.dart
|
||||
@@ -244,7 +246,6 @@ lib/model/bulk_ids_dto.dart
|
||||
lib/model/change_password_dto.dart
|
||||
lib/model/check_existing_assets_dto.dart
|
||||
lib/model/check_existing_assets_response_dto.dart
|
||||
lib/model/classification_config.dart
|
||||
lib/model/clip_config.dart
|
||||
lib/model/clip_mode.dart
|
||||
lib/model/colorspace.dart
|
||||
@@ -333,6 +334,7 @@ lib/model/system_config_library_scan_dto.dart
|
||||
lib/model/system_config_logging_dto.dart
|
||||
lib/model/system_config_machine_learning_dto.dart
|
||||
lib/model/system_config_map_dto.dart
|
||||
lib/model/system_config_metrics_dto.dart
|
||||
lib/model/system_config_new_version_check_dto.dart
|
||||
lib/model/system_config_o_auth_dto.dart
|
||||
lib/model/system_config_password_login_dto.dart
|
||||
@@ -408,7 +410,6 @@ test/bulk_ids_dto_test.dart
|
||||
test/change_password_dto_test.dart
|
||||
test/check_existing_assets_dto_test.dart
|
||||
test/check_existing_assets_response_dto_test.dart
|
||||
test/classification_config_test.dart
|
||||
test/clip_config_test.dart
|
||||
test/clip_mode_test.dart
|
||||
test/colorspace_test.dart
|
||||
@@ -451,6 +452,7 @@ test/map_marker_response_dto_test.dart
|
||||
test/map_theme_test.dart
|
||||
test/memory_lane_response_dto_test.dart
|
||||
test/merge_person_dto_test.dart
|
||||
test/metrics_api_test.dart
|
||||
test/model_type_test.dart
|
||||
test/o_auth_api_test.dart
|
||||
test/o_auth_authorize_response_dto_test.dart
|
||||
@@ -507,6 +509,7 @@ test/system_config_library_scan_dto_test.dart
|
||||
test/system_config_logging_dto_test.dart
|
||||
test/system_config_machine_learning_dto_test.dart
|
||||
test/system_config_map_dto_test.dart
|
||||
test/system_config_metrics_dto_test.dart
|
||||
test/system_config_new_version_check_dto_test.dart
|
||||
test/system_config_o_auth_dto_test.dart
|
||||
test/system_config_password_login_dto_test.dart
|
||||
|
||||
3
mobile/openapi/README.md
generated
3
mobile/openapi/README.md
generated
@@ -145,6 +145,7 @@ Class | Method | HTTP request | Description
|
||||
*LibraryApi* | [**removeOfflineFiles**](doc//LibraryApi.md#removeofflinefiles) | **POST** /library/{id}/removeOffline |
|
||||
*LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /library/{id}/scan |
|
||||
*LibraryApi* | [**updateLibrary**](doc//LibraryApi.md#updatelibrary) | **PUT** /library/{id} |
|
||||
*MetricsApi* | [**getMetrics**](doc//MetricsApi.md#getmetrics) | **GET** /metrics |
|
||||
*OAuthApi* | [**finishOAuth**](doc//OAuthApi.md#finishoauth) | **POST** /oauth/callback |
|
||||
*OAuthApi* | [**generateOAuthConfig**](doc//OAuthApi.md#generateoauthconfig) | **POST** /oauth/config |
|
||||
*OAuthApi* | [**linkOAuthAccount**](doc//OAuthApi.md#linkoauthaccount) | **POST** /oauth/link |
|
||||
@@ -252,7 +253,6 @@ Class | Method | HTTP request | Description
|
||||
- [ChangePasswordDto](doc//ChangePasswordDto.md)
|
||||
- [CheckExistingAssetsDto](doc//CheckExistingAssetsDto.md)
|
||||
- [CheckExistingAssetsResponseDto](doc//CheckExistingAssetsResponseDto.md)
|
||||
- [ClassificationConfig](doc//ClassificationConfig.md)
|
||||
- [Colorspace](doc//Colorspace.md)
|
||||
- [CreateAlbumDto](doc//CreateAlbumDto.md)
|
||||
- [CreateLibraryDto](doc//CreateLibraryDto.md)
|
||||
@@ -338,6 +338,7 @@ Class | Method | HTTP request | Description
|
||||
- [SystemConfigLoggingDto](doc//SystemConfigLoggingDto.md)
|
||||
- [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md)
|
||||
- [SystemConfigMapDto](doc//SystemConfigMapDto.md)
|
||||
- [SystemConfigMetricsDto](doc//SystemConfigMetricsDto.md)
|
||||
- [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)
|
||||
- [SystemConfigOAuthDto](doc//SystemConfigOAuthDto.md)
|
||||
- [SystemConfigPasswordLoginDto](doc//SystemConfigPasswordLoginDto.md)
|
||||
|
||||
1
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
1
mobile/openapi/doc/AllJobStatusResponseDto.md
generated
@@ -12,7 +12,6 @@ Name | Type | Description | Notes
|
||||
**library_** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||
**metadataExtraction** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||
**migration** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||
**objectTagging** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||
**recognizeFaces** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||
**search** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||
**sidecar** | [**JobStatusDto**](JobStatusDto.md) | |
|
||||
|
||||
65
mobile/openapi/doc/MetricsApi.md
generated
Normal file
65
mobile/openapi/doc/MetricsApi.md
generated
Normal file
@@ -0,0 +1,65 @@
|
||||
# openapi.api.MetricsApi
|
||||
|
||||
## Load the API package
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
```
|
||||
|
||||
All URIs are relative to */api*
|
||||
|
||||
Method | HTTP request | Description
|
||||
------------- | ------------- | -------------
|
||||
[**getMetrics**](MetricsApi.md#getmetrics) | **GET** /metrics |
|
||||
|
||||
|
||||
# **getMetrics**
|
||||
> Object getMetrics()
|
||||
|
||||
|
||||
|
||||
### Example
|
||||
```dart
|
||||
import 'package:openapi/api.dart';
|
||||
// TODO Configure API key authorization: cookie
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('cookie').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure API key authorization: api_key
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKey = 'YOUR_API_KEY';
|
||||
// uncomment below to setup prefix (e.g. Bearer) for API key, if needed
|
||||
//defaultApiClient.getAuthentication<ApiKeyAuth>('api_key').apiKeyPrefix = 'Bearer';
|
||||
// TODO Configure HTTP Bearer authorization: bearer
|
||||
// Case 1. Use String Token
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken('YOUR_ACCESS_TOKEN');
|
||||
// Case 2. Use Function which generate token.
|
||||
// String yourTokenGeneratorFunction() { ... }
|
||||
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
|
||||
|
||||
final api_instance = MetricsApi();
|
||||
|
||||
try {
|
||||
final result = api_instance.getMetrics();
|
||||
print(result);
|
||||
} catch (e) {
|
||||
print('Exception when calling MetricsApi->getMetrics: $e\n');
|
||||
}
|
||||
```
|
||||
|
||||
### Parameters
|
||||
This endpoint does not need any parameter.
|
||||
|
||||
### Return type
|
||||
|
||||
[**Object**](Object.md)
|
||||
|
||||
### Authorization
|
||||
|
||||
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
|
||||
|
||||
### HTTP request headers
|
||||
|
||||
- **Content-Type**: Not defined
|
||||
- **Accept**: application/json
|
||||
|
||||
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
|
||||
|
||||
2
mobile/openapi/doc/ServerFeaturesDto.md
generated
2
mobile/openapi/doc/ServerFeaturesDto.md
generated
@@ -12,13 +12,13 @@ Name | Type | Description | Notes
|
||||
**configFile** | **bool** | |
|
||||
**facialRecognition** | **bool** | |
|
||||
**map** | **bool** | |
|
||||
**metrics** | **bool** | |
|
||||
**oauth** | **bool** | |
|
||||
**oauthAutoLaunch** | **bool** | |
|
||||
**passwordLogin** | **bool** | |
|
||||
**reverseGeocoding** | **bool** | |
|
||||
**search** | **bool** | |
|
||||
**sidecar** | **bool** | |
|
||||
**tagImage** | **bool** | |
|
||||
**trash** | **bool** | |
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
1
mobile/openapi/doc/SystemConfigDto.md
generated
1
mobile/openapi/doc/SystemConfigDto.md
generated
@@ -14,6 +14,7 @@ Name | Type | Description | Notes
|
||||
**logging** | [**SystemConfigLoggingDto**](SystemConfigLoggingDto.md) | |
|
||||
**machineLearning** | [**SystemConfigMachineLearningDto**](SystemConfigMachineLearningDto.md) | |
|
||||
**map** | [**SystemConfigMapDto**](SystemConfigMapDto.md) | |
|
||||
**metrics** | [**SystemConfigMetricsDto**](SystemConfigMetricsDto.md) | |
|
||||
**newVersionCheck** | [**SystemConfigNewVersionCheckDto**](SystemConfigNewVersionCheckDto.md) | |
|
||||
**oauth** | [**SystemConfigOAuthDto**](SystemConfigOAuthDto.md) | |
|
||||
**passwordLogin** | [**SystemConfigPasswordLoginDto**](SystemConfigPasswordLoginDto.md) | |
|
||||
|
||||
1
mobile/openapi/doc/SystemConfigJobDto.md
generated
1
mobile/openapi/doc/SystemConfigJobDto.md
generated
@@ -12,7 +12,6 @@ Name | Type | Description | Notes
|
||||
**library_** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||
**metadataExtraction** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||
**migration** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||
**objectTagging** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||
**recognizeFaces** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||
**search** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||
**sidecar** | [**JobSettingsDto**](JobSettingsDto.md) | |
|
||||
|
||||
@@ -8,7 +8,6 @@ import 'package:openapi/api.dart';
|
||||
## Properties
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**classification** | [**ClassificationConfig**](ClassificationConfig.md) | |
|
||||
**clip** | [**CLIPConfig**](CLIPConfig.md) | |
|
||||
**enabled** | **bool** | |
|
||||
**facialRecognition** | [**RecognitionConfig**](RecognitionConfig.md) | |
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# openapi.model.ClassificationConfig
|
||||
# openapi.model.SystemConfigMetricsDto
|
||||
|
||||
## Load the model package
|
||||
```dart
|
||||
@@ -9,9 +9,6 @@ import 'package:openapi/api.dart';
|
||||
Name | Type | Description | Notes
|
||||
------------ | ------------- | ------------- | -------------
|
||||
**enabled** | **bool** | |
|
||||
**minScore** | **int** | |
|
||||
**modelName** | **String** | |
|
||||
**modelType** | [**ModelType**](ModelType.md) | | [optional]
|
||||
|
||||
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)
|
||||
|
||||
3
mobile/openapi/lib/api.dart
generated
3
mobile/openapi/lib/api.dart
generated
@@ -37,6 +37,7 @@ part 'api/authentication_api.dart';
|
||||
part 'api/face_api.dart';
|
||||
part 'api/job_api.dart';
|
||||
part 'api/library_api.dart';
|
||||
part 'api/metrics_api.dart';
|
||||
part 'api/o_auth_api.dart';
|
||||
part 'api/partner_api.dart';
|
||||
part 'api/person_api.dart';
|
||||
@@ -88,7 +89,6 @@ part 'model/cq_mode.dart';
|
||||
part 'model/change_password_dto.dart';
|
||||
part 'model/check_existing_assets_dto.dart';
|
||||
part 'model/check_existing_assets_response_dto.dart';
|
||||
part 'model/classification_config.dart';
|
||||
part 'model/colorspace.dart';
|
||||
part 'model/create_album_dto.dart';
|
||||
part 'model/create_library_dto.dart';
|
||||
@@ -174,6 +174,7 @@ part 'model/system_config_library_scan_dto.dart';
|
||||
part 'model/system_config_logging_dto.dart';
|
||||
part 'model/system_config_machine_learning_dto.dart';
|
||||
part 'model/system_config_map_dto.dart';
|
||||
part 'model/system_config_metrics_dto.dart';
|
||||
part 'model/system_config_new_version_check_dto.dart';
|
||||
part 'model/system_config_o_auth_dto.dart';
|
||||
part 'model/system_config_password_login_dto.dart';
|
||||
|
||||
59
mobile/openapi/lib/api/metrics_api.dart
generated
Normal file
59
mobile/openapi/lib/api/metrics_api.dart
generated
Normal file
@@ -0,0 +1,59 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
|
||||
class MetricsApi {
|
||||
MetricsApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
|
||||
|
||||
final ApiClient apiClient;
|
||||
|
||||
/// Performs an HTTP 'GET /metrics' operation and returns the [Response].
|
||||
Future<Response> getMetricsWithHttpInfo() async {
|
||||
// ignore: prefer_const_declarations
|
||||
final path = r'/metrics';
|
||||
|
||||
// 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<Object?> getMetrics() async {
|
||||
final response = await getMetricsWithHttpInfo();
|
||||
if (response.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
|
||||
}
|
||||
// When a remote server returns no body with a status of 204, we shall not decode it.
|
||||
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
|
||||
// FormatException when trying to decode an empty string.
|
||||
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
|
||||
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object;
|
||||
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
4
mobile/openapi/lib/api_client.dart
generated
4
mobile/openapi/lib/api_client.dart
generated
@@ -263,8 +263,6 @@ class ApiClient {
|
||||
return CheckExistingAssetsDto.fromJson(value);
|
||||
case 'CheckExistingAssetsResponseDto':
|
||||
return CheckExistingAssetsResponseDto.fromJson(value);
|
||||
case 'ClassificationConfig':
|
||||
return ClassificationConfig.fromJson(value);
|
||||
case 'Colorspace':
|
||||
return ColorspaceTypeTransformer().decode(value);
|
||||
case 'CreateAlbumDto':
|
||||
@@ -435,6 +433,8 @@ class ApiClient {
|
||||
return SystemConfigMachineLearningDto.fromJson(value);
|
||||
case 'SystemConfigMapDto':
|
||||
return SystemConfigMapDto.fromJson(value);
|
||||
case 'SystemConfigMetricsDto':
|
||||
return SystemConfigMetricsDto.fromJson(value);
|
||||
case 'SystemConfigNewVersionCheckDto':
|
||||
return SystemConfigNewVersionCheckDto.fromJson(value);
|
||||
case 'SystemConfigOAuthDto':
|
||||
|
||||
@@ -17,7 +17,6 @@ class AllJobStatusResponseDto {
|
||||
required this.library_,
|
||||
required this.metadataExtraction,
|
||||
required this.migration,
|
||||
required this.objectTagging,
|
||||
required this.recognizeFaces,
|
||||
required this.search,
|
||||
required this.sidecar,
|
||||
@@ -35,8 +34,6 @@ class AllJobStatusResponseDto {
|
||||
|
||||
JobStatusDto migration;
|
||||
|
||||
JobStatusDto objectTagging;
|
||||
|
||||
JobStatusDto recognizeFaces;
|
||||
|
||||
JobStatusDto search;
|
||||
@@ -57,7 +54,6 @@ class AllJobStatusResponseDto {
|
||||
other.library_ == library_ &&
|
||||
other.metadataExtraction == metadataExtraction &&
|
||||
other.migration == migration &&
|
||||
other.objectTagging == objectTagging &&
|
||||
other.recognizeFaces == recognizeFaces &&
|
||||
other.search == search &&
|
||||
other.sidecar == sidecar &&
|
||||
@@ -73,7 +69,6 @@ class AllJobStatusResponseDto {
|
||||
(library_.hashCode) +
|
||||
(metadataExtraction.hashCode) +
|
||||
(migration.hashCode) +
|
||||
(objectTagging.hashCode) +
|
||||
(recognizeFaces.hashCode) +
|
||||
(search.hashCode) +
|
||||
(sidecar.hashCode) +
|
||||
@@ -83,7 +78,7 @@ class AllJobStatusResponseDto {
|
||||
(videoConversion.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, objectTagging=$objectTagging, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
||||
String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -91,7 +86,6 @@ class AllJobStatusResponseDto {
|
||||
json[r'library'] = this.library_;
|
||||
json[r'metadataExtraction'] = this.metadataExtraction;
|
||||
json[r'migration'] = this.migration;
|
||||
json[r'objectTagging'] = this.objectTagging;
|
||||
json[r'recognizeFaces'] = this.recognizeFaces;
|
||||
json[r'search'] = this.search;
|
||||
json[r'sidecar'] = this.sidecar;
|
||||
@@ -114,7 +108,6 @@ class AllJobStatusResponseDto {
|
||||
library_: JobStatusDto.fromJson(json[r'library'])!,
|
||||
metadataExtraction: JobStatusDto.fromJson(json[r'metadataExtraction'])!,
|
||||
migration: JobStatusDto.fromJson(json[r'migration'])!,
|
||||
objectTagging: JobStatusDto.fromJson(json[r'objectTagging'])!,
|
||||
recognizeFaces: JobStatusDto.fromJson(json[r'recognizeFaces'])!,
|
||||
search: JobStatusDto.fromJson(json[r'search'])!,
|
||||
sidecar: JobStatusDto.fromJson(json[r'sidecar'])!,
|
||||
@@ -173,7 +166,6 @@ class AllJobStatusResponseDto {
|
||||
'library',
|
||||
'metadataExtraction',
|
||||
'migration',
|
||||
'objectTagging',
|
||||
'recognizeFaces',
|
||||
'search',
|
||||
'sidecar',
|
||||
|
||||
131
mobile/openapi/lib/model/classification_config.dart
generated
131
mobile/openapi/lib/model/classification_config.dart
generated
@@ -1,131 +0,0 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class ClassificationConfig {
|
||||
/// Returns a new [ClassificationConfig] instance.
|
||||
ClassificationConfig({
|
||||
required this.enabled,
|
||||
required this.minScore,
|
||||
required this.modelName,
|
||||
this.modelType,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
int minScore;
|
||||
|
||||
String modelName;
|
||||
|
||||
///
|
||||
/// Please note: This property should have been non-nullable! Since the specification file
|
||||
/// does not include a default value (using the "default:" property), however, the generated
|
||||
/// source code must fall back to having a nullable type.
|
||||
/// Consider adding a "default:" property in the specification file to hide this note.
|
||||
///
|
||||
ModelType? modelType;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is ClassificationConfig &&
|
||||
other.enabled == enabled &&
|
||||
other.minScore == minScore &&
|
||||
other.modelName == modelName &&
|
||||
other.modelType == modelType;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode) +
|
||||
(minScore.hashCode) +
|
||||
(modelName.hashCode) +
|
||||
(modelType == null ? 0 : modelType!.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ClassificationConfig[enabled=$enabled, minScore=$minScore, modelName=$modelName, modelType=$modelType]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'minScore'] = this.minScore;
|
||||
json[r'modelName'] = this.modelName;
|
||||
if (this.modelType != null) {
|
||||
json[r'modelType'] = this.modelType;
|
||||
} else {
|
||||
// json[r'modelType'] = null;
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [ClassificationConfig] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static ClassificationConfig? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return ClassificationConfig(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
minScore: mapValueOfType<int>(json, r'minScore')!,
|
||||
modelName: mapValueOfType<String>(json, r'modelName')!,
|
||||
modelType: ModelType.fromJson(json[r'modelType']),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<ClassificationConfig> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <ClassificationConfig>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = ClassificationConfig.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, ClassificationConfig> mapFromJson(dynamic json) {
|
||||
final map = <String, ClassificationConfig>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = ClassificationConfig.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of ClassificationConfig-objects as value to a dart map
|
||||
static Map<String, List<ClassificationConfig>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<ClassificationConfig>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = ClassificationConfig.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
'minScore',
|
||||
'modelName',
|
||||
};
|
||||
}
|
||||
|
||||
3
mobile/openapi/lib/model/job_name.dart
generated
3
mobile/openapi/lib/model/job_name.dart
generated
@@ -26,7 +26,6 @@ class JobName {
|
||||
static const thumbnailGeneration = JobName._(r'thumbnailGeneration');
|
||||
static const metadataExtraction = JobName._(r'metadataExtraction');
|
||||
static const videoConversion = JobName._(r'videoConversion');
|
||||
static const objectTagging = JobName._(r'objectTagging');
|
||||
static const recognizeFaces = JobName._(r'recognizeFaces');
|
||||
static const smartSearch = JobName._(r'smartSearch');
|
||||
static const backgroundTask = JobName._(r'backgroundTask');
|
||||
@@ -41,7 +40,6 @@ class JobName {
|
||||
thumbnailGeneration,
|
||||
metadataExtraction,
|
||||
videoConversion,
|
||||
objectTagging,
|
||||
recognizeFaces,
|
||||
smartSearch,
|
||||
backgroundTask,
|
||||
@@ -91,7 +89,6 @@ class JobNameTypeTransformer {
|
||||
case r'thumbnailGeneration': return JobName.thumbnailGeneration;
|
||||
case r'metadataExtraction': return JobName.metadataExtraction;
|
||||
case r'videoConversion': return JobName.videoConversion;
|
||||
case r'objectTagging': return JobName.objectTagging;
|
||||
case r'recognizeFaces': return JobName.recognizeFaces;
|
||||
case r'smartSearch': return JobName.smartSearch;
|
||||
case r'backgroundTask': return JobName.backgroundTask;
|
||||
|
||||
3
mobile/openapi/lib/model/model_type.dart
generated
3
mobile/openapi/lib/model/model_type.dart
generated
@@ -23,13 +23,11 @@ class ModelType {
|
||||
|
||||
String toJson() => value;
|
||||
|
||||
static const imageClassification = ModelType._(r'image-classification');
|
||||
static const facialRecognition = ModelType._(r'facial-recognition');
|
||||
static const clip = ModelType._(r'clip');
|
||||
|
||||
/// List of all possible values in this [enum][ModelType].
|
||||
static const values = <ModelType>[
|
||||
imageClassification,
|
||||
facialRecognition,
|
||||
clip,
|
||||
];
|
||||
@@ -70,7 +68,6 @@ class ModelTypeTypeTransformer {
|
||||
ModelType? decode(dynamic data, {bool allowNull = true}) {
|
||||
if (data != null) {
|
||||
switch (data) {
|
||||
case r'image-classification': return ModelType.imageClassification;
|
||||
case r'facial-recognition': return ModelType.facialRecognition;
|
||||
case r'clip': return ModelType.clip;
|
||||
default:
|
||||
|
||||
18
mobile/openapi/lib/model/server_features_dto.dart
generated
18
mobile/openapi/lib/model/server_features_dto.dart
generated
@@ -17,13 +17,13 @@ class ServerFeaturesDto {
|
||||
required this.configFile,
|
||||
required this.facialRecognition,
|
||||
required this.map,
|
||||
required this.metrics,
|
||||
required this.oauth,
|
||||
required this.oauthAutoLaunch,
|
||||
required this.passwordLogin,
|
||||
required this.reverseGeocoding,
|
||||
required this.search,
|
||||
required this.sidecar,
|
||||
required this.tagImage,
|
||||
required this.trash,
|
||||
});
|
||||
|
||||
@@ -35,6 +35,8 @@ class ServerFeaturesDto {
|
||||
|
||||
bool map;
|
||||
|
||||
bool metrics;
|
||||
|
||||
bool oauth;
|
||||
|
||||
bool oauthAutoLaunch;
|
||||
@@ -47,8 +49,6 @@ class ServerFeaturesDto {
|
||||
|
||||
bool sidecar;
|
||||
|
||||
bool tagImage;
|
||||
|
||||
bool trash;
|
||||
|
||||
@override
|
||||
@@ -57,13 +57,13 @@ class ServerFeaturesDto {
|
||||
other.configFile == configFile &&
|
||||
other.facialRecognition == facialRecognition &&
|
||||
other.map == map &&
|
||||
other.metrics == metrics &&
|
||||
other.oauth == oauth &&
|
||||
other.oauthAutoLaunch == oauthAutoLaunch &&
|
||||
other.passwordLogin == passwordLogin &&
|
||||
other.reverseGeocoding == reverseGeocoding &&
|
||||
other.search == search &&
|
||||
other.sidecar == sidecar &&
|
||||
other.tagImage == tagImage &&
|
||||
other.trash == trash;
|
||||
|
||||
@override
|
||||
@@ -73,17 +73,17 @@ class ServerFeaturesDto {
|
||||
(configFile.hashCode) +
|
||||
(facialRecognition.hashCode) +
|
||||
(map.hashCode) +
|
||||
(metrics.hashCode) +
|
||||
(oauth.hashCode) +
|
||||
(oauthAutoLaunch.hashCode) +
|
||||
(passwordLogin.hashCode) +
|
||||
(reverseGeocoding.hashCode) +
|
||||
(search.hashCode) +
|
||||
(sidecar.hashCode) +
|
||||
(tagImage.hashCode) +
|
||||
(trash.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'ServerFeaturesDto[clipEncode=$clipEncode, configFile=$configFile, facialRecognition=$facialRecognition, map=$map, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, tagImage=$tagImage, trash=$trash]';
|
||||
String toString() => 'ServerFeaturesDto[clipEncode=$clipEncode, configFile=$configFile, facialRecognition=$facialRecognition, map=$map, metrics=$metrics, oauth=$oauth, oauthAutoLaunch=$oauthAutoLaunch, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, search=$search, sidecar=$sidecar, trash=$trash]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -91,13 +91,13 @@ class ServerFeaturesDto {
|
||||
json[r'configFile'] = this.configFile;
|
||||
json[r'facialRecognition'] = this.facialRecognition;
|
||||
json[r'map'] = this.map;
|
||||
json[r'metrics'] = this.metrics;
|
||||
json[r'oauth'] = this.oauth;
|
||||
json[r'oauthAutoLaunch'] = this.oauthAutoLaunch;
|
||||
json[r'passwordLogin'] = this.passwordLogin;
|
||||
json[r'reverseGeocoding'] = this.reverseGeocoding;
|
||||
json[r'search'] = this.search;
|
||||
json[r'sidecar'] = this.sidecar;
|
||||
json[r'tagImage'] = this.tagImage;
|
||||
json[r'trash'] = this.trash;
|
||||
return json;
|
||||
}
|
||||
@@ -114,13 +114,13 @@ class ServerFeaturesDto {
|
||||
configFile: mapValueOfType<bool>(json, r'configFile')!,
|
||||
facialRecognition: mapValueOfType<bool>(json, r'facialRecognition')!,
|
||||
map: mapValueOfType<bool>(json, r'map')!,
|
||||
metrics: mapValueOfType<bool>(json, r'metrics')!,
|
||||
oauth: mapValueOfType<bool>(json, r'oauth')!,
|
||||
oauthAutoLaunch: mapValueOfType<bool>(json, r'oauthAutoLaunch')!,
|
||||
passwordLogin: mapValueOfType<bool>(json, r'passwordLogin')!,
|
||||
reverseGeocoding: mapValueOfType<bool>(json, r'reverseGeocoding')!,
|
||||
search: mapValueOfType<bool>(json, r'search')!,
|
||||
sidecar: mapValueOfType<bool>(json, r'sidecar')!,
|
||||
tagImage: mapValueOfType<bool>(json, r'tagImage')!,
|
||||
trash: mapValueOfType<bool>(json, r'trash')!,
|
||||
);
|
||||
}
|
||||
@@ -173,13 +173,13 @@ class ServerFeaturesDto {
|
||||
'configFile',
|
||||
'facialRecognition',
|
||||
'map',
|
||||
'metrics',
|
||||
'oauth',
|
||||
'oauthAutoLaunch',
|
||||
'passwordLogin',
|
||||
'reverseGeocoding',
|
||||
'search',
|
||||
'sidecar',
|
||||
'tagImage',
|
||||
'trash',
|
||||
};
|
||||
}
|
||||
|
||||
10
mobile/openapi/lib/model/system_config_dto.dart
generated
10
mobile/openapi/lib/model/system_config_dto.dart
generated
@@ -19,6 +19,7 @@ class SystemConfigDto {
|
||||
required this.logging,
|
||||
required this.machineLearning,
|
||||
required this.map,
|
||||
required this.metrics,
|
||||
required this.newVersionCheck,
|
||||
required this.oauth,
|
||||
required this.passwordLogin,
|
||||
@@ -41,6 +42,8 @@ class SystemConfigDto {
|
||||
|
||||
SystemConfigMapDto map;
|
||||
|
||||
SystemConfigMetricsDto metrics;
|
||||
|
||||
SystemConfigNewVersionCheckDto newVersionCheck;
|
||||
|
||||
SystemConfigOAuthDto oauth;
|
||||
@@ -65,6 +68,7 @@ class SystemConfigDto {
|
||||
other.logging == logging &&
|
||||
other.machineLearning == machineLearning &&
|
||||
other.map == map &&
|
||||
other.metrics == metrics &&
|
||||
other.newVersionCheck == newVersionCheck &&
|
||||
other.oauth == oauth &&
|
||||
other.passwordLogin == passwordLogin &&
|
||||
@@ -83,6 +87,7 @@ class SystemConfigDto {
|
||||
(logging.hashCode) +
|
||||
(machineLearning.hashCode) +
|
||||
(map.hashCode) +
|
||||
(metrics.hashCode) +
|
||||
(newVersionCheck.hashCode) +
|
||||
(oauth.hashCode) +
|
||||
(passwordLogin.hashCode) +
|
||||
@@ -93,7 +98,7 @@ class SystemConfigDto {
|
||||
(trash.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
|
||||
String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metrics=$metrics, newVersionCheck=$newVersionCheck, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, storageTemplate=$storageTemplate, theme=$theme, thumbnail=$thumbnail, trash=$trash]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -103,6 +108,7 @@ class SystemConfigDto {
|
||||
json[r'logging'] = this.logging;
|
||||
json[r'machineLearning'] = this.machineLearning;
|
||||
json[r'map'] = this.map;
|
||||
json[r'metrics'] = this.metrics;
|
||||
json[r'newVersionCheck'] = this.newVersionCheck;
|
||||
json[r'oauth'] = this.oauth;
|
||||
json[r'passwordLogin'] = this.passwordLogin;
|
||||
@@ -128,6 +134,7 @@ class SystemConfigDto {
|
||||
logging: SystemConfigLoggingDto.fromJson(json[r'logging'])!,
|
||||
machineLearning: SystemConfigMachineLearningDto.fromJson(json[r'machineLearning'])!,
|
||||
map: SystemConfigMapDto.fromJson(json[r'map'])!,
|
||||
metrics: SystemConfigMetricsDto.fromJson(json[r'metrics'])!,
|
||||
newVersionCheck: SystemConfigNewVersionCheckDto.fromJson(json[r'newVersionCheck'])!,
|
||||
oauth: SystemConfigOAuthDto.fromJson(json[r'oauth'])!,
|
||||
passwordLogin: SystemConfigPasswordLoginDto.fromJson(json[r'passwordLogin'])!,
|
||||
@@ -189,6 +196,7 @@ class SystemConfigDto {
|
||||
'logging',
|
||||
'machineLearning',
|
||||
'map',
|
||||
'metrics',
|
||||
'newVersionCheck',
|
||||
'oauth',
|
||||
'passwordLogin',
|
||||
|
||||
10
mobile/openapi/lib/model/system_config_job_dto.dart
generated
10
mobile/openapi/lib/model/system_config_job_dto.dart
generated
@@ -17,7 +17,6 @@ class SystemConfigJobDto {
|
||||
required this.library_,
|
||||
required this.metadataExtraction,
|
||||
required this.migration,
|
||||
required this.objectTagging,
|
||||
required this.recognizeFaces,
|
||||
required this.search,
|
||||
required this.sidecar,
|
||||
@@ -35,8 +34,6 @@ class SystemConfigJobDto {
|
||||
|
||||
JobSettingsDto migration;
|
||||
|
||||
JobSettingsDto objectTagging;
|
||||
|
||||
JobSettingsDto recognizeFaces;
|
||||
|
||||
JobSettingsDto search;
|
||||
@@ -57,7 +54,6 @@ class SystemConfigJobDto {
|
||||
other.library_ == library_ &&
|
||||
other.metadataExtraction == metadataExtraction &&
|
||||
other.migration == migration &&
|
||||
other.objectTagging == objectTagging &&
|
||||
other.recognizeFaces == recognizeFaces &&
|
||||
other.search == search &&
|
||||
other.sidecar == sidecar &&
|
||||
@@ -73,7 +69,6 @@ class SystemConfigJobDto {
|
||||
(library_.hashCode) +
|
||||
(metadataExtraction.hashCode) +
|
||||
(migration.hashCode) +
|
||||
(objectTagging.hashCode) +
|
||||
(recognizeFaces.hashCode) +
|
||||
(search.hashCode) +
|
||||
(sidecar.hashCode) +
|
||||
@@ -83,7 +78,7 @@ class SystemConfigJobDto {
|
||||
(videoConversion.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, objectTagging=$objectTagging, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
||||
String toString() => 'SystemConfigJobDto[backgroundTask=$backgroundTask, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, recognizeFaces=$recognizeFaces, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
@@ -91,7 +86,6 @@ class SystemConfigJobDto {
|
||||
json[r'library'] = this.library_;
|
||||
json[r'metadataExtraction'] = this.metadataExtraction;
|
||||
json[r'migration'] = this.migration;
|
||||
json[r'objectTagging'] = this.objectTagging;
|
||||
json[r'recognizeFaces'] = this.recognizeFaces;
|
||||
json[r'search'] = this.search;
|
||||
json[r'sidecar'] = this.sidecar;
|
||||
@@ -114,7 +108,6 @@ class SystemConfigJobDto {
|
||||
library_: JobSettingsDto.fromJson(json[r'library'])!,
|
||||
metadataExtraction: JobSettingsDto.fromJson(json[r'metadataExtraction'])!,
|
||||
migration: JobSettingsDto.fromJson(json[r'migration'])!,
|
||||
objectTagging: JobSettingsDto.fromJson(json[r'objectTagging'])!,
|
||||
recognizeFaces: JobSettingsDto.fromJson(json[r'recognizeFaces'])!,
|
||||
search: JobSettingsDto.fromJson(json[r'search'])!,
|
||||
sidecar: JobSettingsDto.fromJson(json[r'sidecar'])!,
|
||||
@@ -173,7 +166,6 @@ class SystemConfigJobDto {
|
||||
'library',
|
||||
'metadataExtraction',
|
||||
'migration',
|
||||
'objectTagging',
|
||||
'recognizeFaces',
|
||||
'search',
|
||||
'sidecar',
|
||||
|
||||
@@ -13,15 +13,12 @@ part of openapi.api;
|
||||
class SystemConfigMachineLearningDto {
|
||||
/// Returns a new [SystemConfigMachineLearningDto] instance.
|
||||
SystemConfigMachineLearningDto({
|
||||
required this.classification,
|
||||
required this.clip,
|
||||
required this.enabled,
|
||||
required this.facialRecognition,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
ClassificationConfig classification;
|
||||
|
||||
CLIPConfig clip;
|
||||
|
||||
bool enabled;
|
||||
@@ -32,7 +29,6 @@ class SystemConfigMachineLearningDto {
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigMachineLearningDto &&
|
||||
other.classification == classification &&
|
||||
other.clip == clip &&
|
||||
other.enabled == enabled &&
|
||||
other.facialRecognition == facialRecognition &&
|
||||
@@ -41,18 +37,16 @@ class SystemConfigMachineLearningDto {
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(classification.hashCode) +
|
||||
(clip.hashCode) +
|
||||
(enabled.hashCode) +
|
||||
(facialRecognition.hashCode) +
|
||||
(url.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigMachineLearningDto[classification=$classification, clip=$clip, enabled=$enabled, facialRecognition=$facialRecognition, url=$url]';
|
||||
String toString() => 'SystemConfigMachineLearningDto[clip=$clip, enabled=$enabled, facialRecognition=$facialRecognition, url=$url]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'classification'] = this.classification;
|
||||
json[r'clip'] = this.clip;
|
||||
json[r'enabled'] = this.enabled;
|
||||
json[r'facialRecognition'] = this.facialRecognition;
|
||||
@@ -68,7 +62,6 @@ class SystemConfigMachineLearningDto {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigMachineLearningDto(
|
||||
classification: ClassificationConfig.fromJson(json[r'classification'])!,
|
||||
clip: CLIPConfig.fromJson(json[r'clip'])!,
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
facialRecognition: RecognitionConfig.fromJson(json[r'facialRecognition'])!,
|
||||
@@ -120,7 +113,6 @@ class SystemConfigMachineLearningDto {
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'classification',
|
||||
'clip',
|
||||
'enabled',
|
||||
'facialRecognition',
|
||||
|
||||
98
mobile/openapi/lib/model/system_config_metrics_dto.dart
generated
Normal file
98
mobile/openapi/lib/model/system_config_metrics_dto.dart
generated
Normal file
@@ -0,0 +1,98 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
part of openapi.api;
|
||||
|
||||
class SystemConfigMetricsDto {
|
||||
/// Returns a new [SystemConfigMetricsDto] instance.
|
||||
SystemConfigMetricsDto({
|
||||
required this.enabled,
|
||||
});
|
||||
|
||||
bool enabled;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) => identical(this, other) || other is SystemConfigMetricsDto &&
|
||||
other.enabled == enabled;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
// ignore: unnecessary_parenthesis
|
||||
(enabled.hashCode);
|
||||
|
||||
@override
|
||||
String toString() => 'SystemConfigMetricsDto[enabled=$enabled]';
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
final json = <String, dynamic>{};
|
||||
json[r'enabled'] = this.enabled;
|
||||
return json;
|
||||
}
|
||||
|
||||
/// Returns a new [SystemConfigMetricsDto] instance and imports its values from
|
||||
/// [value] if it's a [Map], null otherwise.
|
||||
// ignore: prefer_constructors_over_static_methods
|
||||
static SystemConfigMetricsDto? fromJson(dynamic value) {
|
||||
if (value is Map) {
|
||||
final json = value.cast<String, dynamic>();
|
||||
|
||||
return SystemConfigMetricsDto(
|
||||
enabled: mapValueOfType<bool>(json, r'enabled')!,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<SystemConfigMetricsDto> listFromJson(dynamic json, {bool growable = false,}) {
|
||||
final result = <SystemConfigMetricsDto>[];
|
||||
if (json is List && json.isNotEmpty) {
|
||||
for (final row in json) {
|
||||
final value = SystemConfigMetricsDto.fromJson(row);
|
||||
if (value != null) {
|
||||
result.add(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toList(growable: growable);
|
||||
}
|
||||
|
||||
static Map<String, SystemConfigMetricsDto> mapFromJson(dynamic json) {
|
||||
final map = <String, SystemConfigMetricsDto>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
|
||||
for (final entry in json.entries) {
|
||||
final value = SystemConfigMetricsDto.fromJson(entry.value);
|
||||
if (value != null) {
|
||||
map[entry.key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
// maps a json object with a list of SystemConfigMetricsDto-objects as value to a dart map
|
||||
static Map<String, List<SystemConfigMetricsDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
|
||||
final map = <String, List<SystemConfigMetricsDto>>{};
|
||||
if (json is Map && json.isNotEmpty) {
|
||||
// ignore: parameter_assignments
|
||||
json = json.cast<String, dynamic>();
|
||||
for (final entry in json.entries) {
|
||||
map[entry.key] = SystemConfigMetricsDto.listFromJson(entry.value, growable: growable,);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/// The list of required keys that must be present in a JSON.
|
||||
static const requiredKeys = <String>{
|
||||
'enabled',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,11 +36,6 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// JobStatusDto objectTagging
|
||||
test('to test the property `objectTagging`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// JobStatusDto recognizeFaces
|
||||
test('to test the property `recognizeFaces`', () async {
|
||||
// TODO
|
||||
|
||||
26
mobile/openapi/test/metrics_api_test.dart
generated
Normal file
26
mobile/openapi/test/metrics_api_test.dart
generated
Normal file
@@ -0,0 +1,26 @@
|
||||
//
|
||||
// AUTO-GENERATED FILE, DO NOT MODIFY!
|
||||
//
|
||||
// @dart=2.12
|
||||
|
||||
// ignore_for_file: unused_element, unused_import
|
||||
// ignore_for_file: always_put_required_named_parameters_first
|
||||
// ignore_for_file: constant_identifier_names
|
||||
// ignore_for_file: lines_longer_than_80_chars
|
||||
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
|
||||
/// tests for MetricsApi
|
||||
void main() {
|
||||
// final instance = MetricsApi();
|
||||
|
||||
group('tests for MetricsApi', () {
|
||||
//Future<Object> getMetrics() async
|
||||
test('test getMetrics', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
10
mobile/openapi/test/server_features_dto_test.dart
generated
10
mobile/openapi/test/server_features_dto_test.dart
generated
@@ -36,6 +36,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool metrics
|
||||
test('to test the property `metrics`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool oauth
|
||||
test('to test the property `oauth`', () async {
|
||||
// TODO
|
||||
@@ -66,11 +71,6 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool tagImage
|
||||
test('to test the property `tagImage`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// bool trash
|
||||
test('to test the property `trash`', () async {
|
||||
// TODO
|
||||
|
||||
5
mobile/openapi/test/system_config_dto_test.dart
generated
5
mobile/openapi/test/system_config_dto_test.dart
generated
@@ -46,6 +46,11 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// SystemConfigMetricsDto metrics
|
||||
test('to test the property `metrics`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// SystemConfigNewVersionCheckDto newVersionCheck
|
||||
test('to test the property `newVersionCheck`', () async {
|
||||
// TODO
|
||||
|
||||
@@ -36,11 +36,6 @@ void main() {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// JobSettingsDto objectTagging
|
||||
test('to test the property `objectTagging`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// JobSettingsDto recognizeFaces
|
||||
test('to test the property `recognizeFaces`', () async {
|
||||
// TODO
|
||||
|
||||
@@ -16,11 +16,6 @@ void main() {
|
||||
// final instance = SystemConfigMachineLearningDto();
|
||||
|
||||
group('test SystemConfigMachineLearningDto', () {
|
||||
// ClassificationConfig classification
|
||||
test('to test the property `classification`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// CLIPConfig clip
|
||||
test('to test the property `clip`', () async {
|
||||
// TODO
|
||||
|
||||
@@ -11,31 +11,16 @@
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
// tests for ClassificationConfig
|
||||
// tests for SystemConfigMetricsDto
|
||||
void main() {
|
||||
// final instance = ClassificationConfig();
|
||||
// final instance = SystemConfigMetricsDto();
|
||||
|
||||
group('test ClassificationConfig', () {
|
||||
group('test SystemConfigMetricsDto', () {
|
||||
// bool enabled
|
||||
test('to test the property `enabled`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// int minScore
|
||||
test('to test the property `minScore`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// String modelName
|
||||
test('to test the property `modelName`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
// ModelType modelType
|
||||
test('to test the property `modelType`', () async {
|
||||
// TODO
|
||||
});
|
||||
|
||||
|
||||
});
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
{
|
||||
"matchFileNames": ["machine-learning/**"],
|
||||
"groupName": "machine-learning",
|
||||
"matchUpdateTypes": ["minor", "patch"],
|
||||
"rangeStrategy": "in-range-only",
|
||||
"schedule": "on tuesday"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -10,7 +10,10 @@ RUN npm ci && \
|
||||
rm -rf node_modules/@img/sharp-libvips* && \
|
||||
rm -rf node_modules/@img/sharp-linuxmusl-x64
|
||||
COPY server .
|
||||
ENV PATH="${PATH}:/usr/src/app/bin"
|
||||
ENV PATH="${PATH}:/usr/src/app/bin" \
|
||||
NODE_ENV=development \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
ENTRYPOINT ["tini", "--", "/bin/sh"]
|
||||
|
||||
|
||||
@@ -34,7 +37,9 @@ RUN npm run build
|
||||
FROM ghcr.io/immich-app/base-server-prod:20231214@sha256:b214f86683fde081b09beed2d7bfc28bec55c829751ccf2e02ad7dd18293f5e0
|
||||
|
||||
WORKDIR /usr/src/app
|
||||
ENV NODE_ENV=production
|
||||
ENV NODE_ENV=production \
|
||||
NVIDIA_DRIVER_CAPABILITIES=all \
|
||||
NVIDIA_VISIBLE_DEVICES=all
|
||||
COPY --from=prod /usr/src/app/node_modules ./node_modules
|
||||
COPY --from=prod /usr/src/app/dist ./dist
|
||||
COPY --from=prod /usr/src/app/bin ./bin
|
||||
|
||||
@@ -3716,6 +3716,38 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/metrics": {
|
||||
"get": {
|
||||
"operationId": "getMetrics",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
"Metrics"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/oauth/authorize": {
|
||||
"post": {
|
||||
"operationId": "startOAuth",
|
||||
@@ -6479,9 +6511,6 @@
|
||||
"migration": {
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"objectTagging": {
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
"recognizeFaces": {
|
||||
"$ref": "#/components/schemas/JobStatusDto"
|
||||
},
|
||||
@@ -6508,7 +6537,6 @@
|
||||
"thumbnailGeneration",
|
||||
"metadataExtraction",
|
||||
"videoConversion",
|
||||
"objectTagging",
|
||||
"smartSearch",
|
||||
"storageTemplateMigration",
|
||||
"migration",
|
||||
@@ -7201,28 +7229,6 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClassificationConfig": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"minScore": {
|
||||
"type": "integer"
|
||||
},
|
||||
"modelName": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelType": {
|
||||
"$ref": "#/components/schemas/ModelType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"minScore",
|
||||
"enabled",
|
||||
"modelName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Colorspace": {
|
||||
"enum": [
|
||||
"srgb",
|
||||
@@ -7819,7 +7825,6 @@
|
||||
"thumbnailGeneration",
|
||||
"metadataExtraction",
|
||||
"videoConversion",
|
||||
"objectTagging",
|
||||
"recognizeFaces",
|
||||
"smartSearch",
|
||||
"backgroundTask",
|
||||
@@ -8090,7 +8095,6 @@
|
||||
},
|
||||
"ModelType": {
|
||||
"enum": [
|
||||
"image-classification",
|
||||
"facial-recognition",
|
||||
"clip"
|
||||
],
|
||||
@@ -8656,6 +8660,9 @@
|
||||
"map": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"metrics": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"oauth": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -8674,9 +8681,6 @@
|
||||
"sidecar": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"tagImage": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"trash": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -8686,14 +8690,14 @@
|
||||
"configFile",
|
||||
"facialRecognition",
|
||||
"map",
|
||||
"metrics",
|
||||
"trash",
|
||||
"reverseGeocoding",
|
||||
"oauth",
|
||||
"oauthAutoLaunch",
|
||||
"passwordLogin",
|
||||
"sidecar",
|
||||
"search",
|
||||
"tagImage"
|
||||
"search"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -9059,6 +9063,9 @@
|
||||
"map": {
|
||||
"$ref": "#/components/schemas/SystemConfigMapDto"
|
||||
},
|
||||
"metrics": {
|
||||
"$ref": "#/components/schemas/SystemConfigMetricsDto"
|
||||
},
|
||||
"newVersionCheck": {
|
||||
"$ref": "#/components/schemas/SystemConfigNewVersionCheckDto"
|
||||
},
|
||||
@@ -9089,6 +9096,7 @@
|
||||
"logging",
|
||||
"machineLearning",
|
||||
"map",
|
||||
"metrics",
|
||||
"newVersionCheck",
|
||||
"oauth",
|
||||
"passwordLogin",
|
||||
@@ -9191,9 +9199,6 @@
|
||||
"migration": {
|
||||
"$ref": "#/components/schemas/JobSettingsDto"
|
||||
},
|
||||
"objectTagging": {
|
||||
"$ref": "#/components/schemas/JobSettingsDto"
|
||||
},
|
||||
"recognizeFaces": {
|
||||
"$ref": "#/components/schemas/JobSettingsDto"
|
||||
},
|
||||
@@ -9220,7 +9225,6 @@
|
||||
"thumbnailGeneration",
|
||||
"metadataExtraction",
|
||||
"videoConversion",
|
||||
"objectTagging",
|
||||
"smartSearch",
|
||||
"storageTemplateMigration",
|
||||
"migration",
|
||||
@@ -9275,9 +9279,6 @@
|
||||
},
|
||||
"SystemConfigMachineLearningDto": {
|
||||
"properties": {
|
||||
"classification": {
|
||||
"$ref": "#/components/schemas/ClassificationConfig"
|
||||
},
|
||||
"clip": {
|
||||
"$ref": "#/components/schemas/CLIPConfig"
|
||||
},
|
||||
@@ -9294,7 +9295,6 @@
|
||||
"required": [
|
||||
"enabled",
|
||||
"url",
|
||||
"classification",
|
||||
"clip",
|
||||
"facialRecognition"
|
||||
],
|
||||
@@ -9319,6 +9319,17 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigMetricsDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SystemConfigNewVersionCheckDto": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
|
||||
134
server/src/domain/database/database.service.spec.ts
Normal file
134
server/src/domain/database/database.service.spec.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { ImmichLogger } from '@app/infra/logger';
|
||||
import { newDatabaseRepositoryMock } from '@test';
|
||||
import { Version } from '../domain.constant';
|
||||
import { DatabaseExtension, IDatabaseRepository } from '../repositories';
|
||||
import { DatabaseService } from './database.service';
|
||||
|
||||
describe(DatabaseService.name, () => {
|
||||
let sut: DatabaseService;
|
||||
let databaseMock: jest.Mocked<IDatabaseRepository>;
|
||||
let fatalLog: jest.SpyInstance;
|
||||
|
||||
beforeEach(async () => {
|
||||
databaseMock = newDatabaseRepositoryMock();
|
||||
fatalLog = jest.spyOn(ImmichLogger.prototype, 'fatal');
|
||||
|
||||
sut = new DatabaseService(databaseMock);
|
||||
|
||||
sut.minVectorsVersion = new Version(0, 1, 1);
|
||||
sut.maxVectorsVersion = new Version(0, 1, 11);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fatalLog.mockRestore();
|
||||
});
|
||||
|
||||
it('should work', () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('init', () => {
|
||||
it('should return if minimum supported vectors version is installed', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValueOnce(new Version(0, 1, 1));
|
||||
|
||||
await sut.init();
|
||||
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledWith(DatabaseExtension.VECTORS);
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(fatalLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return if maximum supported vectors version is installed', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValueOnce(new Version(0, 1, 11));
|
||||
|
||||
await sut.init();
|
||||
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledWith(DatabaseExtension.VECTORS);
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).toHaveBeenCalledTimes(1);
|
||||
expect(fatalLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if vectors version is not installed even after createVectors', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(sut.init()).rejects.toThrow('Unexpected: The pgvecto.rs extension is not installed.');
|
||||
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if vectors version is below minimum supported version', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValueOnce(new Version(0, 0, 1));
|
||||
|
||||
await expect(sut.init()).rejects.toThrow(/('tensorchord\/pgvecto-rs:pg14-v0.1.11')/s);
|
||||
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if vectors version is above maximum supported version', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValueOnce(new Version(0, 1, 12));
|
||||
|
||||
await expect(sut.init()).rejects.toThrow(
|
||||
/('DROP EXTENSION IF EXISTS vectors').*('tensorchord\/pgvecto-rs:pg14-v0\.1\.11')/s,
|
||||
);
|
||||
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw an error if vectors version is a nightly', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValueOnce(new Version(0, 0, 0));
|
||||
|
||||
await expect(sut.init()).rejects.toThrow(
|
||||
/(nightly).*('DROP EXTENSION IF EXISTS vectors').*('tensorchord\/pgvecto-rs:pg14-v0\.1\.11')/s,
|
||||
);
|
||||
|
||||
expect(databaseMock.getExtensionVersion).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw error if vectors extension could not be created', async () => {
|
||||
databaseMock.createExtension.mockRejectedValueOnce(new Error('Failed to create extension'));
|
||||
|
||||
await expect(sut.init()).rejects.toThrow('Failed to create extension');
|
||||
|
||||
expect(fatalLog).toHaveBeenCalledTimes(1);
|
||||
expect(fatalLog.mock.calls[0][0]).toMatch(/('tensorchord\/pgvecto-rs:pg14-v0\.1\.11').*(v1\.91\.0)/s);
|
||||
expect(databaseMock.createExtension).toHaveBeenCalledTimes(1);
|
||||
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
for (const major of [14, 15, 16]) {
|
||||
it(`should suggest image with postgres ${major} if database is ${major}`, async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValue(new Version(0, 0, 1));
|
||||
databaseMock.getPostgresVersion.mockResolvedValueOnce(new Version(major, 0, 0));
|
||||
|
||||
await expect(sut.init()).rejects.toThrow(new RegExp(`tensorchord\/pgvecto-rs:pg${major}-v0\\.1\\.11`, 's'));
|
||||
});
|
||||
}
|
||||
|
||||
it('should not suggest image if postgres version is not in 14, 15 or 16', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValue(new Version(0, 0, 1));
|
||||
[13, 17].forEach((major) => databaseMock.getPostgresVersion.mockResolvedValueOnce(new Version(major, 0, 0)));
|
||||
|
||||
await expect(sut.init()).rejects.toThrow(/^(?:(?!tensorchord\/pgvecto-rs).)*$/s);
|
||||
await expect(sut.init()).rejects.toThrow(/^(?:(?!tensorchord\/pgvecto-rs).)*$/s);
|
||||
});
|
||||
|
||||
it('should set the image to the maximum supported version', async () => {
|
||||
databaseMock.getExtensionVersion.mockResolvedValue(new Version(0, 0, 1));
|
||||
|
||||
await expect(sut.init()).rejects.toThrow(/('tensorchord\/pgvecto-rs:pg14-v0\.1\.11')/s);
|
||||
|
||||
sut.maxVectorsVersion = new Version(0, 1, 12);
|
||||
await expect(sut.init()).rejects.toThrow(/('tensorchord\/pgvecto-rs:pg14-v0\.1\.12')/s);
|
||||
});
|
||||
});
|
||||
});
|
||||
69
server/src/domain/database/database.service.ts
Normal file
69
server/src/domain/database/database.service.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { ImmichLogger } from '@app/infra/logger';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { QueryFailedError } from 'typeorm';
|
||||
import { Version } from '../domain.constant';
|
||||
import { DatabaseExtension, IDatabaseRepository } from '../repositories';
|
||||
|
||||
@Injectable()
|
||||
export class DatabaseService {
|
||||
private logger = new ImmichLogger(DatabaseService.name);
|
||||
minVectorsVersion = new Version(0, 1, 1);
|
||||
maxVectorsVersion = new Version(0, 1, 11);
|
||||
|
||||
constructor(@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository) {}
|
||||
|
||||
async init() {
|
||||
await this.createVectors();
|
||||
await this.assertVectors();
|
||||
await this.databaseRepository.runMigrations();
|
||||
}
|
||||
|
||||
private async assertVectors() {
|
||||
const version = await this.databaseRepository.getExtensionVersion(DatabaseExtension.VECTORS);
|
||||
if (version == null) {
|
||||
throw new Error('Unexpected: The pgvecto.rs extension is not installed.');
|
||||
}
|
||||
|
||||
const image = await this.getVectorsImage();
|
||||
const suggestion = image ? `, such as with the docker image '${image}'` : '';
|
||||
|
||||
if (version.isEqual(new Version(0, 0, 0))) {
|
||||
throw new Error(
|
||||
`The pgvecto.rs extension version is ${version}, which means it is a nightly release.` +
|
||||
`Please run 'DROP EXTENSION IF EXISTS vectors' and switch to a release version${suggestion}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (version.isNewerThan(this.maxVectorsVersion)) {
|
||||
throw new Error(`
|
||||
The pgvecto.rs extension version is ${version} instead of ${this.maxVectorsVersion}.
|
||||
Please run 'DROP EXTENSION IF EXISTS vectors' and switch to ${this.maxVectorsVersion}${suggestion}.`);
|
||||
}
|
||||
|
||||
if (version.isOlderThan(this.minVectorsVersion)) {
|
||||
throw new Error(`
|
||||
The pgvecto.rs extension version is ${version}, which is older than the minimum supported version ${this.minVectorsVersion}.
|
||||
Please upgrade to this version or later${suggestion}.`);
|
||||
}
|
||||
}
|
||||
|
||||
private async createVectors() {
|
||||
await this.databaseRepository.createExtension(DatabaseExtension.VECTORS).catch(async (err: QueryFailedError) => {
|
||||
const image = await this.getVectorsImage();
|
||||
this.logger.fatal(`
|
||||
Failed to create pgvecto.rs extension.
|
||||
If you have not updated your Postgres instance to a docker image that supports pgvecto.rs (such as '${image}'), please do so.
|
||||
See the v1.91.0 release notes for more info: https://github.com/immich-app/immich/releases/tag/v1.91.0'
|
||||
`);
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
|
||||
private async getVectorsImage() {
|
||||
const { major } = await this.databaseRepository.getPostgresVersion();
|
||||
if (![14, 15, 16].includes(major)) {
|
||||
return null;
|
||||
}
|
||||
return `tensorchord/pgvecto-rs:pg${major}-v${this.maxVectorsVersion}`;
|
||||
}
|
||||
}
|
||||
1
server/src/domain/database/index.ts
Normal file
1
server/src/domain/database/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './database.service';
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ServerVersion, mimeTypes } from './domain.constant';
|
||||
import { Version, mimeTypes } from './domain.constant';
|
||||
|
||||
describe('mimeTypes', () => {
|
||||
for (const { mimetype, extension } of [
|
||||
@@ -196,64 +196,76 @@ describe('mimeTypes', () => {
|
||||
});
|
||||
|
||||
describe('ServerVersion', () => {
|
||||
const tests = [
|
||||
{ this: new Version(0, 0, 1), other: new Version(0, 0, 0), expected: 1 },
|
||||
{ this: new Version(0, 1, 0), other: new Version(0, 0, 0), expected: 1 },
|
||||
{ this: new Version(1, 0, 0), other: new Version(0, 0, 0), expected: 1 },
|
||||
{ this: new Version(0, 0, 0), other: new Version(0, 0, 1), expected: -1 },
|
||||
{ this: new Version(0, 0, 0), other: new Version(0, 1, 0), expected: -1 },
|
||||
{ this: new Version(0, 0, 0), other: new Version(1, 0, 0), expected: -1 },
|
||||
{ this: new Version(0, 0, 0), other: new Version(0, 0, 0), expected: 0 },
|
||||
{ this: new Version(0, 0, 1), other: new Version(0, 0, 1), expected: 0 },
|
||||
{ this: new Version(0, 1, 0), other: new Version(0, 1, 0), expected: 0 },
|
||||
{ this: new Version(1, 0, 0), other: new Version(1, 0, 0), expected: 0 },
|
||||
{ this: new Version(1, 0), other: new Version(1, 0, 0), expected: 0 },
|
||||
{ this: new Version(1, 0), other: new Version(1, 0, 1), expected: -1 },
|
||||
{ this: new Version(1, 1), other: new Version(1, 0, 1), expected: 1 },
|
||||
{ this: new Version(1), other: new Version(1, 0, 0), expected: 0 },
|
||||
{ this: new Version(1), other: new Version(1, 0, 1), expected: -1 },
|
||||
];
|
||||
|
||||
describe('compare', () => {
|
||||
for (const { this: thisVersion, other: otherVersion, expected } of tests) {
|
||||
it(`should return ${expected} when comparing ${thisVersion} to ${otherVersion}`, () => {
|
||||
expect(thisVersion.compare(otherVersion)).toEqual(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isOlderThan', () => {
|
||||
for (const { this: thisVersion, other: otherVersion, expected } of tests) {
|
||||
const bool = expected < 0;
|
||||
it(`should return ${bool} when comparing ${thisVersion} to ${otherVersion}`, () => {
|
||||
expect(thisVersion.isOlderThan(otherVersion)).toEqual(bool);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isEqual', () => {
|
||||
for (const { this: thisVersion, other: otherVersion, expected } of tests) {
|
||||
const bool = expected === 0;
|
||||
it(`should return ${bool} when comparing ${thisVersion} to ${otherVersion}`, () => {
|
||||
expect(thisVersion.isEqual(otherVersion)).toEqual(bool);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('isNewerThan', () => {
|
||||
it('should work on patch versions', () => {
|
||||
expect(new ServerVersion(0, 0, 1).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true);
|
||||
expect(new ServerVersion(1, 72, 1).isNewerThan(new ServerVersion(1, 72, 0))).toBe(true);
|
||||
|
||||
expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(0, 0, 1))).toBe(false);
|
||||
expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 72, 1))).toBe(false);
|
||||
});
|
||||
|
||||
it('should work on minor versions', () => {
|
||||
expect(new ServerVersion(0, 1, 0).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true);
|
||||
expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 71, 0))).toBe(true);
|
||||
expect(new ServerVersion(1, 72, 0).isNewerThan(new ServerVersion(1, 71, 9))).toBe(true);
|
||||
|
||||
expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(0, 1, 0))).toBe(false);
|
||||
expect(new ServerVersion(1, 71, 0).isNewerThan(new ServerVersion(1, 72, 0))).toBe(false);
|
||||
expect(new ServerVersion(1, 71, 9).isNewerThan(new ServerVersion(1, 72, 0))).toBe(false);
|
||||
});
|
||||
|
||||
it('should work on major versions', () => {
|
||||
expect(new ServerVersion(1, 0, 0).isNewerThan(new ServerVersion(0, 0, 0))).toBe(true);
|
||||
expect(new ServerVersion(2, 0, 0).isNewerThan(new ServerVersion(1, 71, 0))).toBe(true);
|
||||
|
||||
expect(new ServerVersion(0, 0, 0).isNewerThan(new ServerVersion(1, 0, 0))).toBe(false);
|
||||
expect(new ServerVersion(1, 71, 0).isNewerThan(new ServerVersion(2, 0, 0))).toBe(false);
|
||||
});
|
||||
|
||||
it('should work on equal', () => {
|
||||
for (const version of [
|
||||
new ServerVersion(0, 0, 0),
|
||||
new ServerVersion(0, 0, 1),
|
||||
new ServerVersion(0, 1, 1),
|
||||
new ServerVersion(0, 1, 0),
|
||||
new ServerVersion(1, 1, 1),
|
||||
new ServerVersion(1, 0, 0),
|
||||
new ServerVersion(1, 72, 1),
|
||||
new ServerVersion(1, 72, 0),
|
||||
new ServerVersion(1, 73, 9),
|
||||
]) {
|
||||
expect(version.isNewerThan(version)).toBe(false);
|
||||
}
|
||||
});
|
||||
for (const { this: thisVersion, other: otherVersion, expected } of tests) {
|
||||
const bool = expected > 0;
|
||||
it(`should return ${bool} when comparing ${thisVersion} to ${otherVersion}`, () => {
|
||||
expect(thisVersion.isNewerThan(otherVersion)).toEqual(bool);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('fromString', () => {
|
||||
const tests = [
|
||||
{ scenario: 'leading v', value: 'v1.72.2', expected: new ServerVersion(1, 72, 2) },
|
||||
{ scenario: 'uppercase v', value: 'V1.72.2', expected: new ServerVersion(1, 72, 2) },
|
||||
{ scenario: 'missing v', value: '1.72.2', expected: new ServerVersion(1, 72, 2) },
|
||||
{ scenario: 'large patch', value: '1.72.123', expected: new ServerVersion(1, 72, 123) },
|
||||
{ scenario: 'large minor', value: '1.123.0', expected: new ServerVersion(1, 123, 0) },
|
||||
{ scenario: 'large major', value: '123.0.0', expected: new ServerVersion(123, 0, 0) },
|
||||
{ scenario: 'major bump', value: 'v2.0.0', expected: new ServerVersion(2, 0, 0) },
|
||||
{ scenario: 'leading v', value: 'v1.72.2', expected: new Version(1, 72, 2) },
|
||||
{ scenario: 'uppercase v', value: 'V1.72.2', expected: new Version(1, 72, 2) },
|
||||
{ scenario: 'missing v', value: '1.72.2', expected: new Version(1, 72, 2) },
|
||||
{ scenario: 'large patch', value: '1.72.123', expected: new Version(1, 72, 123) },
|
||||
{ scenario: 'large minor', value: '1.123.0', expected: new Version(1, 123, 0) },
|
||||
{ scenario: 'large major', value: '123.0.0', expected: new Version(123, 0, 0) },
|
||||
{ scenario: 'major bump', value: 'v2.0.0', expected: new Version(2, 0, 0) },
|
||||
{ scenario: 'has dash', value: '14.10-1', expected: new Version(14, 10, 1) },
|
||||
{ scenario: 'missing patch', value: '14.10', expected: new Version(14, 10, 0) },
|
||||
{ scenario: 'only major', value: '14', expected: new Version(14, 0, 0) },
|
||||
];
|
||||
|
||||
for (const { scenario, value, expected } of tests) {
|
||||
it(`should correctly parse ${scenario}`, () => {
|
||||
const actual = ServerVersion.fromString(value);
|
||||
const actual = Version.fromString(value);
|
||||
expect(actual.major).toEqual(expected.major);
|
||||
expect(actual.minor).toEqual(expected.minor);
|
||||
expect(actual.patch).toEqual(expected.patch);
|
||||
|
||||
@@ -5,18 +5,19 @@ import pkg from 'src/../../package.json';
|
||||
|
||||
export const AUDIT_LOG_MAX_DURATION = Duration.fromObject({ days: 100 });
|
||||
export const ONE_HOUR = Duration.fromObject({ hours: 1 });
|
||||
export const TWENTY_FOUR_HOURS = Duration.fromObject({ hours: 24 });
|
||||
|
||||
export interface IServerVersion {
|
||||
export interface IVersion {
|
||||
major: number;
|
||||
minor: number;
|
||||
patch: number;
|
||||
}
|
||||
|
||||
export class ServerVersion implements IServerVersion {
|
||||
export class Version implements IVersion {
|
||||
constructor(
|
||||
public readonly major: number,
|
||||
public readonly minor: number,
|
||||
public readonly patch: number,
|
||||
public readonly minor: number = 0,
|
||||
public readonly patch: number = 0,
|
||||
) {}
|
||||
|
||||
toString() {
|
||||
@@ -28,33 +29,45 @@ export class ServerVersion implements IServerVersion {
|
||||
return { major, minor, patch };
|
||||
}
|
||||
|
||||
static fromString(version: string): ServerVersion {
|
||||
const regex = /(?:v)?(?<major>\d+)\.(?<minor>\d+)\.(?<patch>\d+)/i;
|
||||
static fromString(version: string): Version {
|
||||
const regex = /(?:v)?(?<major>\d+)(?:\.(?<minor>\d+))?(?:[\.-](?<patch>\d+))?/i;
|
||||
const matchResult = version.match(regex);
|
||||
if (matchResult) {
|
||||
const [, major, minor, patch] = matchResult.map(Number);
|
||||
return new ServerVersion(major, minor, patch);
|
||||
const { major, minor = '0', patch = '0' } = matchResult.groups as { [K in keyof IVersion]: string };
|
||||
return new Version(Number(major), Number(minor), Number(patch));
|
||||
} else {
|
||||
throw new Error(`Invalid version format: ${version}`);
|
||||
}
|
||||
}
|
||||
|
||||
isNewerThan(version: ServerVersion): boolean {
|
||||
const equalMajor = this.major === version.major;
|
||||
const equalMinor = this.minor === version.minor;
|
||||
compare(version: Version): number {
|
||||
for (const key of ['major', 'minor', 'patch'] as const) {
|
||||
const diff = this[key] - version[key];
|
||||
if (diff !== 0) {
|
||||
return diff > 0 ? 1 : -1;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
this.major > version.major ||
|
||||
(equalMajor && this.minor > version.minor) ||
|
||||
(equalMajor && equalMinor && this.patch > version.patch)
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
|
||||
isOlderThan(version: Version): boolean {
|
||||
return this.compare(version) < 0;
|
||||
}
|
||||
|
||||
isEqual(version: Version): boolean {
|
||||
return this.compare(version) === 0;
|
||||
}
|
||||
|
||||
isNewerThan(version: Version): boolean {
|
||||
return this.compare(version) > 0;
|
||||
}
|
||||
}
|
||||
|
||||
export const envName = (process.env.NODE_ENV || 'development').toUpperCase();
|
||||
export const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
export const serverVersion = ServerVersion.fromString(pkg.version);
|
||||
export const serverVersion = Version.fromString(pkg.version);
|
||||
|
||||
export const APP_MEDIA_LOCATION = process.env.IMMICH_MEDIA_LOCATION || './upload';
|
||||
|
||||
|
||||
@@ -6,10 +6,12 @@ import { APIKeyService } from './api-key';
|
||||
import { AssetService } from './asset';
|
||||
import { AuditService } from './audit';
|
||||
import { AuthService } from './auth';
|
||||
import { DatabaseService } from './database';
|
||||
import { JobService } from './job';
|
||||
import { LibraryService } from './library';
|
||||
import { MediaService } from './media';
|
||||
import { MetadataService } from './metadata';
|
||||
import { MetricsService } from './metrics';
|
||||
import { PartnerService } from './partner';
|
||||
import { PersonService } from './person';
|
||||
import { SearchService } from './search';
|
||||
@@ -29,9 +31,11 @@ const providers: Provider[] = [
|
||||
AssetService,
|
||||
AuditService,
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
JobService,
|
||||
MediaService,
|
||||
MetadataService,
|
||||
MetricsService,
|
||||
LibraryService,
|
||||
PersonService,
|
||||
PartnerService,
|
||||
@@ -47,8 +51,9 @@ const providers: Provider[] = [
|
||||
ImmichLogger,
|
||||
{
|
||||
provide: INITIAL_SYSTEM_CONFIG,
|
||||
inject: [SystemConfigService],
|
||||
useFactory: async (configService: SystemConfigService) => {
|
||||
inject: [SystemConfigService, DatabaseService],
|
||||
useFactory: async (configService: SystemConfigService, databaseService: DatabaseService) => {
|
||||
await databaseService.init();
|
||||
return configService.getConfig();
|
||||
},
|
||||
},
|
||||
|
||||
@@ -5,6 +5,7 @@ export * from './api-key';
|
||||
export * from './asset';
|
||||
export * from './audit';
|
||||
export * from './auth';
|
||||
export * from './database';
|
||||
export * from './domain.config';
|
||||
export * from './domain.constant';
|
||||
export * from './domain.module';
|
||||
@@ -13,6 +14,7 @@ export * from './job';
|
||||
export * from './library';
|
||||
export * from './media';
|
||||
export * from './metadata';
|
||||
export * from './metrics';
|
||||
export * from './partner';
|
||||
export * from './person';
|
||||
export * from './repositories';
|
||||
|
||||
@@ -2,7 +2,6 @@ export enum QueueName {
|
||||
THUMBNAIL_GENERATION = 'thumbnailGeneration',
|
||||
METADATA_EXTRACTION = 'metadataExtraction',
|
||||
VIDEO_CONVERSION = 'videoConversion',
|
||||
OBJECT_TAGGING = 'objectTagging',
|
||||
RECOGNIZE_FACES = 'recognizeFaces',
|
||||
SMART_SEARCH = 'smartSearch',
|
||||
BACKGROUND_TASK = 'backgroundTask',
|
||||
@@ -55,10 +54,6 @@ export enum JobName {
|
||||
MIGRATE_ASSET = 'migrate-asset',
|
||||
MIGRATE_PERSON = 'migrate-person',
|
||||
|
||||
// object tagging
|
||||
QUEUE_OBJECT_TAGGING = 'queue-object-tagging',
|
||||
CLASSIFY_IMAGE = 'classify-image',
|
||||
|
||||
// facial recognition
|
||||
PERSON_CLEANUP = 'person-cleanup',
|
||||
PERSON_DELETE = 'person-delete',
|
||||
@@ -86,6 +81,9 @@ export enum JobName {
|
||||
SIDECAR_DISCOVERY = 'sidecar-discovery',
|
||||
SIDECAR_SYNC = 'sidecar-sync',
|
||||
SIDECAR_WRITE = 'sidecar-write',
|
||||
|
||||
// metrics
|
||||
METRICS = 'metrics',
|
||||
}
|
||||
|
||||
export const JOBS_ASSET_PAGINATION_SIZE = 1000;
|
||||
@@ -100,6 +98,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
[JobName.CLEAN_OLD_AUDIT_LOGS]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.PERSON_DELETE]: QueueName.BACKGROUND_TASK,
|
||||
[JobName.METRICS]: QueueName.BACKGROUND_TASK,
|
||||
|
||||
// conversion
|
||||
[JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION,
|
||||
@@ -126,10 +125,6 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
|
||||
[JobName.MIGRATE_ASSET]: QueueName.MIGRATION,
|
||||
[JobName.MIGRATE_PERSON]: QueueName.MIGRATION,
|
||||
|
||||
// object tagging
|
||||
[JobName.QUEUE_OBJECT_TAGGING]: QueueName.OBJECT_TAGGING,
|
||||
[JobName.CLASSIFY_IMAGE]: QueueName.OBJECT_TAGGING,
|
||||
|
||||
// facial recognition
|
||||
[JobName.QUEUE_RECOGNIZE_FACES]: QueueName.RECOGNIZE_FACES,
|
||||
[JobName.RECOGNIZE_FACES]: QueueName.RECOGNIZE_FACES,
|
||||
|
||||
@@ -59,9 +59,6 @@ export class AllJobStatusResponseDto implements Record<QueueName, JobStatusDto>
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.VIDEO_CONVERSION]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.OBJECT_TAGGING]!: JobStatusDto;
|
||||
|
||||
@ApiProperty({ type: JobStatusDto })
|
||||
[QueueName.SMART_SEARCH]!: JobStatusDto;
|
||||
|
||||
|
||||
@@ -99,7 +99,6 @@ describe(JobService.name, () => {
|
||||
[QueueName.BACKGROUND_TASK]: expectedJobStatus,
|
||||
[QueueName.SMART_SEARCH]: expectedJobStatus,
|
||||
[QueueName.METADATA_EXTRACTION]: expectedJobStatus,
|
||||
[QueueName.OBJECT_TAGGING]: expectedJobStatus,
|
||||
[QueueName.SEARCH]: expectedJobStatus,
|
||||
[QueueName.STORAGE_TEMPLATE_MIGRATION]: expectedJobStatus,
|
||||
[QueueName.MIGRATION]: expectedJobStatus,
|
||||
@@ -157,17 +156,6 @@ describe(JobService.name, () => {
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.STORAGE_TEMPLATE_MIGRATION });
|
||||
});
|
||||
|
||||
it('should handle a start object tagging command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED, value: true },
|
||||
]);
|
||||
|
||||
await sut.handleCommand(QueueName.OBJECT_TAGGING, { command: JobCommand.START, force: false });
|
||||
|
||||
expect(jobMock.queue).toHaveBeenCalledWith({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force: false } });
|
||||
});
|
||||
|
||||
it('should handle a start clip encoding command', async () => {
|
||||
jobMock.getQueueStatus.mockResolvedValue({ isActive: false, isPaused: false });
|
||||
|
||||
@@ -234,7 +222,6 @@ describe(JobService.name, () => {
|
||||
[QueueName.BACKGROUND_TASK]: { concurrency: 10 },
|
||||
[QueueName.SMART_SEARCH]: { concurrency: 10 },
|
||||
[QueueName.METADATA_EXTRACTION]: { concurrency: 10 },
|
||||
[QueueName.OBJECT_TAGGING]: { concurrency: 10 },
|
||||
[QueueName.RECOGNIZE_FACES]: { concurrency: 10 },
|
||||
[QueueName.SEARCH]: { concurrency: 10 },
|
||||
[QueueName.SIDECAR]: { concurrency: 10 },
|
||||
@@ -249,7 +236,6 @@ describe(JobService.name, () => {
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.BACKGROUND_TASK, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SMART_SEARCH, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.METADATA_EXTRACTION, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.OBJECT_TAGGING, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.RECOGNIZE_FACES, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.SIDECAR, 10);
|
||||
expect(jobMock.setConcurrency).toHaveBeenCalledWith(QueueName.LIBRARY, 10);
|
||||
@@ -292,7 +278,6 @@ describe(JobService.name, () => {
|
||||
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } },
|
||||
jobs: [
|
||||
JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
JobName.CLASSIFY_IMAGE,
|
||||
JobName.ENCODE_CLIP,
|
||||
JobName.RECOGNIZE_FACES,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
@@ -302,7 +287,6 @@ describe(JobService.name, () => {
|
||||
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1', source: 'upload' } },
|
||||
jobs: [
|
||||
JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
JobName.CLASSIFY_IMAGE,
|
||||
JobName.ENCODE_CLIP,
|
||||
JobName.RECOGNIZE_FACES,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
@@ -312,7 +296,6 @@ describe(JobService.name, () => {
|
||||
{
|
||||
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-live-image', source: 'upload' } },
|
||||
jobs: [
|
||||
JobName.CLASSIFY_IMAGE,
|
||||
JobName.GENERATE_WEBP_THUMBNAIL,
|
||||
JobName.RECOGNIZE_FACES,
|
||||
JobName.GENERATE_THUMBHASH_THUMBNAIL,
|
||||
@@ -320,10 +303,6 @@ describe(JobService.name, () => {
|
||||
JobName.VIDEO_CONVERSION,
|
||||
],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } },
|
||||
jobs: [],
|
||||
},
|
||||
{
|
||||
item: { name: JobName.ENCODE_CLIP, data: { id: 'asset-1' } },
|
||||
jobs: [],
|
||||
@@ -371,11 +350,6 @@ describe(JobService.name, () => {
|
||||
feature: FeatureFlag.CLIP_ENCODE,
|
||||
configKey: SystemConfigKey.MACHINE_LEARNING_CLIP_ENABLED,
|
||||
},
|
||||
{
|
||||
queue: QueueName.OBJECT_TAGGING,
|
||||
feature: FeatureFlag.TAG_IMAGE,
|
||||
configKey: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED,
|
||||
},
|
||||
{
|
||||
queue: QueueName.RECOGNIZE_FACES,
|
||||
feature: FeatureFlag.FACIAL_RECOGNITION,
|
||||
|
||||
@@ -94,10 +94,6 @@ export class JobService {
|
||||
case QueueName.MIGRATION:
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_MIGRATION });
|
||||
|
||||
case QueueName.OBJECT_TAGGING:
|
||||
await this.configCore.requireFeature(FeatureFlag.TAG_IMAGE);
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_OBJECT_TAGGING, data: { force } });
|
||||
|
||||
case QueueName.SMART_SEARCH:
|
||||
await this.configCore.requireFeature(FeatureFlag.CLIP_ENCODE);
|
||||
return this.jobRepository.queue({ name: JobName.QUEUE_ENCODE_CLIP, data: { force } });
|
||||
@@ -209,7 +205,6 @@ export class JobService {
|
||||
case JobName.GENERATE_JPEG_THUMBNAIL: {
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
|
||||
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });
|
||||
|
||||
|
||||
2
server/src/domain/metrics/index.ts
Normal file
2
server/src/domain/metrics/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './metrics.dto';
|
||||
export * from './metrics.service';
|
||||
31
server/src/domain/metrics/metrics.dto.ts
Normal file
31
server/src/domain/metrics/metrics.dto.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
class MetricsServerInfo {
|
||||
cpuCount!: number;
|
||||
cpuModel!: string;
|
||||
memory!: number;
|
||||
version!: string;
|
||||
}
|
||||
|
||||
class MetricsAssetCount {
|
||||
image!: number;
|
||||
video!: number;
|
||||
total!: number;
|
||||
}
|
||||
|
||||
export interface Metrics {
|
||||
serverInfo: {
|
||||
cpuCount: number;
|
||||
cpuModel: string;
|
||||
memory: number;
|
||||
version: string;
|
||||
};
|
||||
assetCount: {
|
||||
image: number;
|
||||
video: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
|
||||
export class MetricsDto implements Metrics {
|
||||
serverInfo!: MetricsServerInfo;
|
||||
assetCount!: MetricsAssetCount;
|
||||
}
|
||||
60
server/src/domain/metrics/metrics.service.ts
Normal file
60
server/src/domain/metrics/metrics.service.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { isDev, serverVersion } from '../domain.constant';
|
||||
import { JobName } from '../job';
|
||||
import { ISystemConfigRepository } from '../repositories';
|
||||
import { IJobRepository } from '../repositories/job.repository';
|
||||
import { IMetricsRepository } from '../repositories/metrics.repository';
|
||||
import { FeatureFlag, SystemConfigCore } from '../system-config';
|
||||
import { MetricsDto } from './metrics.dto';
|
||||
|
||||
@Injectable()
|
||||
export class MetricsService {
|
||||
private configCore: SystemConfigCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
@Inject(IMetricsRepository) private repository: IMetricsRepository,
|
||||
@Inject(ISystemConfigRepository) systemConfigRepository: ISystemConfigRepository,
|
||||
) {
|
||||
this.configCore = SystemConfigCore.create(systemConfigRepository);
|
||||
}
|
||||
|
||||
async handleQueueMetrics() {
|
||||
if (!(await this.configCore.hasFeature(FeatureFlag.METRICS))) {
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO
|
||||
// if (isDev) {
|
||||
// return;
|
||||
// }
|
||||
|
||||
await this.jobRepository.queue({ name: JobName.METRICS });
|
||||
}
|
||||
|
||||
async handleSendMetrics() {
|
||||
const metrics = await this.getMetrics();
|
||||
|
||||
await this.repository.sendMetrics(metrics);
|
||||
return true;
|
||||
}
|
||||
|
||||
async getMetrics() {
|
||||
const metrics = new MetricsDto();
|
||||
|
||||
metrics.serverInfo = {
|
||||
cpuCount: this.repository.getCpuCount(),
|
||||
cpuModel: this.repository.getCpuModel(),
|
||||
memory: this.repository.getMemory(),
|
||||
version: serverVersion.toString(),
|
||||
};
|
||||
|
||||
metrics.assetCount = {
|
||||
image: await this.repository.getImageCount(),
|
||||
video: await this.repository.getVideoCount(),
|
||||
total: await this.repository.getAssetCount(),
|
||||
};
|
||||
|
||||
return metrics;
|
||||
}
|
||||
}
|
||||
@@ -278,13 +278,55 @@ describe(PersonService.name, () => {
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
});
|
||||
|
||||
expect(personMock.getById).toHaveBeenCalledWith('person-1');
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
||||
expect(jobMock.queue).not.toHaveBeenCalled();
|
||||
expect(accessMock.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if birthDate is in the future', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.noBirthDate);
|
||||
personMock.update.mockResolvedValue(personStub.withBirthDate);
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
const futureDate = new Date();
|
||||
futureDate.setMinutes(futureDate.getMinutes() + 1); // Set birthDate to one minute in the future
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { birthDate: futureDate })).rejects.toThrow(
|
||||
new BadRequestException('Date of birth cannot be in the future'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not throw an error if birthdate is in the past', async () => {
|
||||
const pastDate = new Date();
|
||||
pastDate.setMinutes(pastDate.getMinutes() - 1); // Set birthDate to one minute in the past
|
||||
|
||||
personMock.getById.mockResolvedValue(personStub.noBirthDate);
|
||||
personMock.update.mockResolvedValue({ ...personStub.withBirthDate, birthDate: pastDate });
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
const result = await sut.update(authStub.admin, 'person-1', { birthDate: pastDate });
|
||||
|
||||
expect(result.birthDate).toEqual(pastDate);
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: pastDate });
|
||||
});
|
||||
|
||||
it('should not throw an error if birthdate is today', async () => {
|
||||
const today = new Date(); // Set birthDate to now()
|
||||
|
||||
personMock.getById.mockResolvedValue(personStub.noBirthDate);
|
||||
personMock.update.mockResolvedValue({ ...personStub.withBirthDate, birthDate: today });
|
||||
personMock.getAssets.mockResolvedValue([assetStub.image]);
|
||||
accessMock.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
|
||||
const result = await sut.update(authStub.admin, 'person-1', { birthDate: today });
|
||||
|
||||
expect(result.birthDate).toEqual(today);
|
||||
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: today });
|
||||
});
|
||||
|
||||
it('should update a person visibility', async () => {
|
||||
personMock.getById.mockResolvedValue(personStub.hidden);
|
||||
personMock.update.mockResolvedValue(personStub.withName);
|
||||
|
||||
@@ -199,6 +199,11 @@ export class PersonService {
|
||||
|
||||
const { name, birthDate, isHidden, featureFaceAssetId: assetId } = dto;
|
||||
|
||||
// Check if the birthDate is in the future
|
||||
if (birthDate && new Date(birthDate) > new Date()) {
|
||||
throw new BadRequestException('Date of birth cannot be in the future');
|
||||
}
|
||||
|
||||
if (name !== undefined || birthDate !== undefined || isHidden !== undefined) {
|
||||
person = await this.repository.update({ id, name, birthDate, isHidden });
|
||||
}
|
||||
|
||||
16
server/src/domain/repositories/database.repository.ts
Normal file
16
server/src/domain/repositories/database.repository.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Version } from '../domain.constant';
|
||||
|
||||
export enum DatabaseExtension {
|
||||
CUBE = 'cube',
|
||||
EARTH_DISTANCE = 'earthdistance',
|
||||
VECTORS = 'vectors',
|
||||
}
|
||||
|
||||
export const IDatabaseRepository = 'IDatabaseRepository';
|
||||
|
||||
export interface IDatabaseRepository {
|
||||
getExtensionVersion(extName: string): Promise<Version | null>;
|
||||
getPostgresVersion(): Promise<Version>;
|
||||
createExtension(extension: DatabaseExtension): Promise<void>;
|
||||
runMigrations(options?: { transaction?: 'all' | 'none' | 'each' }): Promise<void>;
|
||||
}
|
||||
@@ -6,11 +6,13 @@ export * from './asset.repository';
|
||||
export * from './audit.repository';
|
||||
export * from './communication.repository';
|
||||
export * from './crypto.repository';
|
||||
export * from './database.repository';
|
||||
export * from './job.repository';
|
||||
export * from './library.repository';
|
||||
export * from './machine-learning.repository';
|
||||
export * from './media.repository';
|
||||
export * from './metadata.repository';
|
||||
export * from './metrics.repository';
|
||||
export * from './move.repository';
|
||||
export * from './partner.repository';
|
||||
export * from './person.repository';
|
||||
|
||||
@@ -62,10 +62,6 @@ export type JobItem =
|
||||
| { name: JobName.SIDECAR_SYNC; data: IEntityJob }
|
||||
| { name: JobName.SIDECAR_WRITE; data: ISidecarWriteJob }
|
||||
|
||||
// Object Tagging
|
||||
| { name: JobName.QUEUE_OBJECT_TAGGING; data: IBaseJob }
|
||||
| { name: JobName.CLASSIFY_IMAGE; data: IEntityJob }
|
||||
|
||||
// Recognize Faces
|
||||
| { name: JobName.QUEUE_RECOGNIZE_FACES; data: IBaseJob }
|
||||
| { name: JobName.RECOGNIZE_FACES; data: IEntityJob }
|
||||
@@ -93,7 +89,10 @@ export type JobItem =
|
||||
| { name: JobName.LIBRARY_REMOVE_OFFLINE; data: IEntityJob }
|
||||
| { name: JobName.LIBRARY_DELETE; data: IEntityJob }
|
||||
| { name: JobName.LIBRARY_QUEUE_SCAN_ALL; data: IBaseJob }
|
||||
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob };
|
||||
| { name: JobName.LIBRARY_QUEUE_CLEANUP; data: IBaseJob }
|
||||
|
||||
// Metrics
|
||||
| { name: JobName.METRICS; data?: IBaseJob };
|
||||
|
||||
export type JobHandler<T = any> = (data: T) => boolean | Promise<boolean>;
|
||||
export type JobItemHandler = (item: JobItem) => Promise<void>;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClassificationConfig, CLIPConfig, RecognitionConfig } from '../smart-info/dto';
|
||||
import { CLIPConfig, RecognitionConfig } from '../smart-info/dto';
|
||||
|
||||
export const IMachineLearningRepository = 'IMachineLearningRepository';
|
||||
|
||||
@@ -26,7 +26,6 @@ export interface DetectFaceResult {
|
||||
}
|
||||
|
||||
export enum ModelType {
|
||||
IMAGE_CLASSIFICATION = 'image-classification',
|
||||
FACIAL_RECOGNITION = 'facial-recognition',
|
||||
CLIP = 'clip',
|
||||
}
|
||||
@@ -37,7 +36,6 @@ export enum CLIPMode {
|
||||
}
|
||||
|
||||
export interface IMachineLearningRepository {
|
||||
classifyImage(url: string, input: VisionModelInput, config: ClassificationConfig): Promise<string[]>;
|
||||
encodeImage(url: string, input: VisionModelInput, config: CLIPConfig): Promise<number[]>;
|
||||
encodeText(url: string, input: TextModelInput, config: CLIPConfig): Promise<number[]>;
|
||||
detectFaces(url: string, input: VisionModelInput, config: RecognitionConfig): Promise<DetectFaceResult[]>;
|
||||
|
||||
13
server/src/domain/repositories/metrics.repository.ts
Normal file
13
server/src/domain/repositories/metrics.repository.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { MetricsDto } from '../metrics';
|
||||
|
||||
export const IMetricsRepository = 'IMetricsRepository';
|
||||
|
||||
export interface IMetricsRepository {
|
||||
getAssetCount(): Promise<number>;
|
||||
getCpuCount(): number;
|
||||
getCpuModel(): string;
|
||||
getMemory(): number;
|
||||
getImageCount(): Promise<number>;
|
||||
getVideoCount(): Promise<number>;
|
||||
sendMetrics(payload: MetricsDto): Promise<void>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FeatureFlags, IServerVersion } from '@app/domain';
|
||||
import { FeatureFlags, IVersion } from '@app/domain';
|
||||
import { ApiProperty, ApiResponseProperty } from '@nestjs/swagger';
|
||||
import { SystemConfigThemeDto } from '../system-config/dto/system-config-theme.dto';
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ServerInfoResponseDto {
|
||||
diskUsagePercentage!: number;
|
||||
}
|
||||
|
||||
export class ServerVersionResponseDto implements IServerVersion {
|
||||
export class ServerVersionResponseDto implements IVersion {
|
||||
@ApiProperty({ type: 'integer' })
|
||||
major!: number;
|
||||
@ApiProperty({ type: 'integer' })
|
||||
@@ -93,6 +93,7 @@ export class ServerFeaturesDto implements FeatureFlags {
|
||||
configFile!: boolean;
|
||||
facialRecognition!: boolean;
|
||||
map!: boolean;
|
||||
metrics!: boolean;
|
||||
trash!: boolean;
|
||||
reverseGeocoding!: boolean;
|
||||
oauth!: boolean;
|
||||
@@ -100,5 +101,4 @@ export class ServerFeaturesDto implements FeatureFlags {
|
||||
passwordLogin!: boolean;
|
||||
sidecar!: boolean;
|
||||
search!: boolean;
|
||||
tagImage!: boolean;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,6 @@ describe(ServerInfoService.name, () => {
|
||||
passwordLogin: true,
|
||||
search: true,
|
||||
sidecar: true,
|
||||
tagImage: false,
|
||||
configFile: false,
|
||||
trash: true,
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImmichLogger } from '@app/infra/logger';
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { ServerVersion, isDev, mimeTypes, serverVersion } from '../domain.constant';
|
||||
import { Version, isDev, mimeTypes, serverVersion } from '../domain.constant';
|
||||
import { asHumanReadable } from '../domain.util';
|
||||
import {
|
||||
ClientEvent,
|
||||
@@ -138,7 +138,7 @@ export class ServerInfoService {
|
||||
}
|
||||
|
||||
const githubRelease = await this.repository.getGitHubRelease();
|
||||
const githubVersion = ServerVersion.fromString(githubRelease.tag_name);
|
||||
const githubVersion = Version.fromString(githubRelease.tag_name);
|
||||
const publishedAt = new Date(githubRelease.published_at);
|
||||
this.releaseVersion = githubVersion;
|
||||
this.releaseVersionCheckedAt = DateTime.now();
|
||||
|
||||
@@ -18,15 +18,6 @@ export class ModelConfig {
|
||||
modelType?: ModelType;
|
||||
}
|
||||
|
||||
export class ClassificationConfig extends ModelConfig {
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
@Max(1)
|
||||
@Type(() => Number)
|
||||
@ApiProperty({ type: 'integer' })
|
||||
minScore!: number;
|
||||
}
|
||||
|
||||
export class CLIPConfig extends ModelConfig {
|
||||
@IsEnum(CLIPMode)
|
||||
@Optional()
|
||||
|
||||
@@ -47,107 +47,6 @@ describe(SmartInfoService.name, () => {
|
||||
expect(sut).toBeDefined();
|
||||
});
|
||||
|
||||
describe('handleQueueObjectTagging', () => {
|
||||
beforeEach(async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED, value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should do nothing if machine learning is disabled', async () => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
|
||||
|
||||
await sut.handleQueueObjectTagging({});
|
||||
|
||||
expect(assetMock.getAll).not.toHaveBeenCalled();
|
||||
expect(assetMock.getWithout).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should queue the assets without tags', async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED, value: true },
|
||||
]);
|
||||
assetMock.getWithout.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueObjectTagging({ force: false });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetStub.image.id } }]]);
|
||||
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.OBJECT_TAGS);
|
||||
});
|
||||
|
||||
it('should queue all the assets', async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED, value: true },
|
||||
]);
|
||||
assetMock.getAll.mockResolvedValue({
|
||||
items: [assetStub.image],
|
||||
hasNextPage: false,
|
||||
});
|
||||
|
||||
await sut.handleQueueObjectTagging({ force: true });
|
||||
|
||||
expect(jobMock.queue.mock.calls).toEqual([[{ name: JobName.CLASSIFY_IMAGE, data: { id: assetStub.image.id } }]]);
|
||||
expect(assetMock.getAll).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleClassifyImage', () => {
|
||||
it('should do nothing if machine learning is disabled', async () => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
|
||||
|
||||
await sut.handleClassifyImage({ id: '123' });
|
||||
|
||||
expect(machineMock.classifyImage).not.toHaveBeenCalled();
|
||||
expect(assetMock.getByIds).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip assets without a resize path', async () => {
|
||||
const asset = { resizePath: '' } as AssetEntity;
|
||||
assetMock.getByIds.mockResolvedValue([asset]);
|
||||
|
||||
await sut.handleClassifyImage({ id: asset.id });
|
||||
|
||||
expect(smartMock.upsert).not.toHaveBeenCalled();
|
||||
expect(machineMock.classifyImage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should save the returned tags', async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED, value: true },
|
||||
]);
|
||||
machineMock.classifyImage.mockResolvedValue(['tag1', 'tag2', 'tag3']);
|
||||
|
||||
await sut.handleClassifyImage({ id: asset.id });
|
||||
|
||||
expect(machineMock.classifyImage).toHaveBeenCalledWith(
|
||||
'http://immich-machine-learning:3003',
|
||||
{
|
||||
imagePath: 'path/to/resize.ext',
|
||||
},
|
||||
{ enabled: true, minScore: 0.9, modelName: 'microsoft/resnet-50' },
|
||||
);
|
||||
expect(smartMock.upsert).toHaveBeenCalledWith({
|
||||
assetId: 'asset-1',
|
||||
tags: ['tag1', 'tag2', 'tag3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should always overwrite old tags', async () => {
|
||||
configMock.load.mockResolvedValue([
|
||||
{ key: SystemConfigKey.MACHINE_LEARNING_CLASSIFICATION_ENABLED, value: true },
|
||||
]);
|
||||
machineMock.classifyImage.mockResolvedValue([]);
|
||||
|
||||
await sut.handleClassifyImage({ id: asset.id });
|
||||
|
||||
expect(machineMock.classifyImage).toHaveBeenCalled();
|
||||
expect(smartMock.upsert).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleQueueEncodeClip', () => {
|
||||
it('should do nothing if machine learning is disabled', async () => {
|
||||
configMock.load.mockResolvedValue([{ key: SystemConfigKey.MACHINE_LEARNING_ENABLED, value: false }]);
|
||||
|
||||
@@ -46,48 +46,6 @@ export class SmartInfoService {
|
||||
await this.jobRepository.resume(QueueName.SMART_SEARCH);
|
||||
}
|
||||
|
||||
async handleQueueObjectTagging({ force }: IBaseJob) {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
if (!machineLearning.enabled || !machineLearning.classification.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const assetPagination = usePagination(JOBS_ASSET_PAGINATION_SIZE, (pagination) => {
|
||||
return force
|
||||
? this.assetRepository.getAll(pagination)
|
||||
: this.assetRepository.getWithout(pagination, WithoutProperty.OBJECT_TAGS);
|
||||
});
|
||||
|
||||
for await (const assets of assetPagination) {
|
||||
for (const asset of assets) {
|
||||
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: { id: asset.id } });
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleClassifyImage({ id }: IEntityJob) {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
if (!machineLearning.enabled || !machineLearning.classification.enabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [asset] = await this.assetRepository.getByIds([id]);
|
||||
if (!asset.resizePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tags = await this.machineLearning.classifyImage(
|
||||
machineLearning.url,
|
||||
{ imagePath: asset.resizePath },
|
||||
machineLearning.classification,
|
||||
);
|
||||
await this.repository.upsert({ assetId: asset.id, tags });
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async handleQueueEncodeClip({ force }: IBaseJob) {
|
||||
const { machineLearning } = await this.configCore.getConfig();
|
||||
if (!machineLearning.enabled || !machineLearning.clip.enabled) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './system-config-ffmpeg.dto';
|
||||
export * from './system-config-library.dto';
|
||||
export * from './system-config-metrics.dto';
|
||||
export * from './system-config-oauth.dto';
|
||||
export * from './system-config-password-login.dto';
|
||||
export * from './system-config-storage-template.dto';
|
||||
|
||||
@@ -29,12 +29,6 @@ export class SystemConfigJobDto implements Record<QueueName, JobSettingsDto> {
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.VIDEO_CONVERSION]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
@Type(() => JobSettingsDto)
|
||||
[QueueName.OBJECT_TAGGING]!: JobSettingsDto;
|
||||
|
||||
@ApiProperty({ type: JobSettingsDto })
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ClassificationConfig, CLIPConfig, RecognitionConfig } from '@app/domain';
|
||||
import { CLIPConfig, RecognitionConfig } from '@app/domain';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsBoolean, IsObject, IsUrl, ValidateIf, ValidateNested } from 'class-validator';
|
||||
|
||||
@@ -10,11 +10,6 @@ export class SystemConfigMachineLearningDto {
|
||||
@ValidateIf((dto) => dto.enabled)
|
||||
url!: string;
|
||||
|
||||
@Type(() => ClassificationConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
classification!: ClassificationConfig;
|
||||
|
||||
@Type(() => CLIPConfig)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class SystemConfigMetricsDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { SystemConfig } from '@app/infra/entities';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsObject, ValidateNested } from 'class-validator';
|
||||
import { SystemConfigMetricsDto } from '.';
|
||||
import { SystemConfigFFmpegDto } from './system-config-ffmpeg.dto';
|
||||
import { SystemConfigJobDto } from './system-config-job.dto';
|
||||
import { SystemConfigLibraryDto } from './system-config-library.dto';
|
||||
@@ -37,6 +38,11 @@ export class SystemConfigDto implements SystemConfig {
|
||||
@IsObject()
|
||||
map!: SystemConfigMapDto;
|
||||
|
||||
@Type(() => SystemConfigMetricsDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
metrics!: SystemConfigMetricsDto;
|
||||
|
||||
@Type(() => SystemConfigNewVersionCheckDto)
|
||||
@ValidateNested()
|
||||
@IsObject()
|
||||
|
||||
@@ -49,7 +49,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
|
||||
[QueueName.SMART_SEARCH]: { concurrency: 2 },
|
||||
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
|
||||
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
|
||||
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
|
||||
[QueueName.SEARCH]: { concurrency: 5 },
|
||||
[QueueName.SIDECAR]: { concurrency: 5 },
|
||||
@@ -66,11 +65,6 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
machineLearning: {
|
||||
enabled: process.env.IMMICH_MACHINE_LEARNING_ENABLED !== 'false',
|
||||
url: process.env.IMMICH_MACHINE_LEARNING_URL || 'http://immich-machine-learning:3003',
|
||||
classification: {
|
||||
enabled: false,
|
||||
modelName: 'microsoft/resnet-50',
|
||||
minScore: 0.9,
|
||||
},
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
@@ -88,6 +82,9 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
lightStyle: '',
|
||||
darkStyle: '',
|
||||
},
|
||||
metrics: {
|
||||
enabled: false,
|
||||
},
|
||||
reverseGeocoding: {
|
||||
enabled: true,
|
||||
},
|
||||
@@ -137,8 +134,8 @@ export const defaults = Object.freeze<SystemConfig>({
|
||||
export enum FeatureFlag {
|
||||
CLIP_ENCODE = 'clipEncode',
|
||||
FACIAL_RECOGNITION = 'facialRecognition',
|
||||
TAG_IMAGE = 'tagImage',
|
||||
MAP = 'map',
|
||||
METRICS = 'metrics',
|
||||
REVERSE_GEOCODING = 'reverseGeocoding',
|
||||
SIDECAR = 'sidecar',
|
||||
SEARCH = 'search',
|
||||
@@ -182,8 +179,6 @@ export class SystemConfigCore {
|
||||
throw new BadRequestException('Clip encoding is not enabled');
|
||||
case FeatureFlag.FACIAL_RECOGNITION:
|
||||
throw new BadRequestException('Facial recognition is not enabled');
|
||||
case FeatureFlag.TAG_IMAGE:
|
||||
throw new BadRequestException('Image tagging is not enabled');
|
||||
case FeatureFlag.SIDECAR:
|
||||
throw new BadRequestException('Sidecar is not enabled');
|
||||
case FeatureFlag.SEARCH:
|
||||
@@ -212,8 +207,8 @@ export class SystemConfigCore {
|
||||
return {
|
||||
[FeatureFlag.CLIP_ENCODE]: mlEnabled && config.machineLearning.clip.enabled,
|
||||
[FeatureFlag.FACIAL_RECOGNITION]: mlEnabled && config.machineLearning.facialRecognition.enabled,
|
||||
[FeatureFlag.TAG_IMAGE]: mlEnabled && config.machineLearning.classification.enabled,
|
||||
[FeatureFlag.MAP]: config.map.enabled,
|
||||
[FeatureFlag.METRICS]: config.metrics.enabled,
|
||||
[FeatureFlag.REVERSE_GEOCODING]: config.reverseGeocoding.enabled,
|
||||
[FeatureFlag.SIDECAR]: true,
|
||||
[FeatureFlag.SEARCH]: true,
|
||||
@@ -245,10 +240,7 @@ export class SystemConfigCore {
|
||||
_.set(config, key, value);
|
||||
}
|
||||
|
||||
const errors = await validate(plainToInstance(SystemConfigDto, config), {
|
||||
forbidNonWhitelisted: true,
|
||||
forbidUnknownValues: true,
|
||||
});
|
||||
const errors = await validate(plainToInstance(SystemConfigDto, config));
|
||||
if (errors.length > 0) {
|
||||
this.logger.error('Validation error', errors);
|
||||
if (configFilePath) {
|
||||
@@ -334,13 +326,13 @@ export class SystemConfigCore {
|
||||
}
|
||||
|
||||
if (!_.isEmpty(file)) {
|
||||
throw new Error(`Unknown keys found: ${JSON.stringify(file)}`);
|
||||
this.logger.warn(`Unknown keys found: ${JSON.stringify(file, null, 2)}`);
|
||||
}
|
||||
|
||||
this.configCache = overrides;
|
||||
} catch (error: Error | any) {
|
||||
this.logger.error(`Unable to load configuration file: ${filepath} due to ${error}`, error?.stack);
|
||||
throw new Error('Invalid configuration file');
|
||||
this.logger.error(`Unable to load configuration file: ${filepath}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
TranscodePolicy,
|
||||
VideoCodec,
|
||||
} from '@app/infra/entities';
|
||||
import { ImmichLogger } from '@app/infra/logger';
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { newCommunicationRepositoryMock, newSystemConfigRepositoryMock } from '@test';
|
||||
import { QueueName } from '../job';
|
||||
@@ -29,7 +30,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
[QueueName.BACKGROUND_TASK]: { concurrency: 5 },
|
||||
[QueueName.SMART_SEARCH]: { concurrency: 2 },
|
||||
[QueueName.METADATA_EXTRACTION]: { concurrency: 5 },
|
||||
[QueueName.OBJECT_TAGGING]: { concurrency: 2 },
|
||||
[QueueName.RECOGNIZE_FACES]: { concurrency: 2 },
|
||||
[QueueName.SEARCH]: { concurrency: 5 },
|
||||
[QueueName.SIDECAR]: { concurrency: 5 },
|
||||
@@ -65,11 +65,6 @@ const updatedConfig = Object.freeze<SystemConfig>({
|
||||
machineLearning: {
|
||||
enabled: true,
|
||||
url: 'http://immich-machine-learning:3003',
|
||||
classification: {
|
||||
enabled: false,
|
||||
modelName: 'microsoft/resnet-50',
|
||||
minScore: 0.9,
|
||||
},
|
||||
clip: {
|
||||
enabled: true,
|
||||
modelName: 'ViT-B-32__openai',
|
||||
@@ -169,6 +164,16 @@ describe(SystemConfigService.name, () => {
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
let warnLog: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
warnLog = jest.spyOn(ImmichLogger.prototype, 'warn');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warnLog.mockRestore();
|
||||
});
|
||||
|
||||
it('should return the default config', async () => {
|
||||
configMock.load.mockResolvedValue([]);
|
||||
|
||||
@@ -217,9 +222,9 @@ describe(SystemConfigService.name, () => {
|
||||
{ should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } },
|
||||
{ should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },
|
||||
{ should: 'validate enums', config: { ffmpeg: { transcode: 'unknown' } } },
|
||||
{ should: 'validate top level unknown options', config: { unknownOption: true } },
|
||||
{ should: 'validate nested unknown options', config: { ffmpeg: { unknownOption: true } } },
|
||||
{ should: 'validate required oauth fields', config: { oauth: { enabled: true } } },
|
||||
{ should: 'warn for top level unknown options', warn: true, config: { unknownOption: true } },
|
||||
{ should: 'warn for nested unknown options', warn: true, config: { ffmpeg: { unknownOption: true } } },
|
||||
];
|
||||
|
||||
for (const test of tests) {
|
||||
@@ -227,7 +232,12 @@ describe(SystemConfigService.name, () => {
|
||||
process.env.IMMICH_CONFIG_FILE = 'immich-config.json';
|
||||
configMock.readFile.mockResolvedValue(JSON.stringify(test.config));
|
||||
|
||||
await expect(sut.getConfig()).rejects.toBeInstanceOf(Error);
|
||||
if (test.warn) {
|
||||
await sut.getConfig();
|
||||
expect(warnLog).toHaveBeenCalled();
|
||||
} else {
|
||||
await expect(sut.getConfig()).rejects.toBeInstanceOf(Error);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
FaceController,
|
||||
JobController,
|
||||
LibraryController,
|
||||
MetricsController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
PersonController,
|
||||
@@ -54,6 +55,7 @@ import { ErrorInterceptor, FileUploadInterceptor } from './interceptors';
|
||||
FaceController,
|
||||
JobController,
|
||||
LibraryController,
|
||||
MetricsController,
|
||||
OAuthController,
|
||||
PartnerController,
|
||||
SearchController,
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import {
|
||||
AuthService,
|
||||
DatabaseService,
|
||||
JobService,
|
||||
LibraryService,
|
||||
MetricsService,
|
||||
ONE_HOUR,
|
||||
OpenGraphTags,
|
||||
ServerInfoService,
|
||||
SharedLinkService,
|
||||
StorageService,
|
||||
SystemConfigService,
|
||||
TWENTY_FOUR_HOURS,
|
||||
WEB_ROOT_PATH,
|
||||
} from '@app/domain';
|
||||
import { ImmichLogger } from '@app/infra/logger';
|
||||
@@ -43,9 +47,12 @@ export class AppService {
|
||||
private authService: AuthService,
|
||||
private configService: SystemConfigService,
|
||||
private jobService: JobService,
|
||||
private libraryService: LibraryService,
|
||||
private metricsService: MetricsService,
|
||||
private serverService: ServerInfoService,
|
||||
private sharedLinkService: SharedLinkService,
|
||||
private storageService: StorageService,
|
||||
private databaseService: DatabaseService,
|
||||
) {}
|
||||
|
||||
@Interval(ONE_HOUR.as('milliseconds'))
|
||||
@@ -53,14 +60,21 @@ export class AppService {
|
||||
await this.serverService.handleVersionCheck();
|
||||
}
|
||||
|
||||
@Interval(TWENTY_FOUR_HOURS.as('milliseconds'))
|
||||
async onMetricsSend() {
|
||||
await this.metricsService.handleQueueMetrics();
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
|
||||
async onNightlyJob() {
|
||||
await this.jobService.handleNightlyJobs();
|
||||
}
|
||||
|
||||
async init() {
|
||||
await this.databaseService.init();
|
||||
await this.configService.init();
|
||||
this.storageService.init();
|
||||
await this.libraryService.init();
|
||||
await this.serverService.handleVersionCheck();
|
||||
this.logger.log(`Feature Flags: ${JSON.stringify(await this.serverService.getFeatures(), null, 2)}`);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user