Merge branch 'immich-app:main' into feat-web-set-asset-as-profile-image

This commit is contained in:
faupau
2023-07-06 20:48:35 +02:00
committed by GitHub
103 changed files with 22119 additions and 221 deletions

View File

@@ -23,11 +23,23 @@ function web {
npx --yes @openapitools/openapi-generator-cli generate -g typescript-axios -i ./immich-openapi-specs.json -o ../web/src/api/open-api -t ./openapi-generator/templates/web --additional-properties=useSingleRequestParameter=true
}
function cli {
rm -rf ../cli/src/api/open-api
cd ./openapi-generator/templates/cli
wget -O apiInner.mustache https://raw.githubusercontent.com/OpenAPITools/openapi-generator/v6.6.0/modules/openapi-generator/src/main/resources/typescript-axios/apiInner.mustache
patch -u apiInner.mustache < apiInner.mustache.patch
cd ../../..
npx --yes @openapitools/openapi-generator-cli generate -g typescript-axios -i ./immich-openapi-specs.json -o ../cli/src/api/open-api -t ./openapi-generator/templates/cli --additional-properties=useSingleRequestParameter=true
}
if [[ $1 == 'mobile' ]]; then
mobile
elif [[ $1 == 'web' ]]; then
web
elif [[ $1 == 'cli' ]]; then
cli
else
mobile
web
cli
fi

View File

@@ -1673,7 +1673,13 @@
"responses": {
"200": {
"content": {
"application/octet-stream": {
"image/jpeg": {
"schema": {
"type": "string",
"format": "binary"
}
},
"image/webp": {
"schema": {
"type": "string",
"format": "binary"
@@ -2704,7 +2710,7 @@
"responses": {
"200": {
"content": {
"application/octet-stream": {
"image/jpeg": {
"schema": {
"type": "string",
"format": "binary"
@@ -4322,7 +4328,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.65.0",
"version": "1.66.1",
"contact": {}
},
"tags": [],
@@ -5816,12 +5822,14 @@
"type": "object",
"properties": {
"name": {
"type": "string"
"type": "string",
"description": "Person name."
},
"featureFaceAssetId": {
"type": "string",
"description": "Asset is used to get the feature face thumbnail."
}
},
"required": [
"name"
]
}
},
"QueueStatusDto": {
"type": "object",

View File

@@ -0,0 +1,391 @@
{{#withSeparateModelsAndApi}}
/* tslint:disable */
/* eslint-disable */
{{>licenseInfo}}
import type { Configuration } from '{{apiRelativeToRoot}}configuration';
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
{{#withNodeImports}}
// URLSearchParams not necessarily used
// @ts-ignore
import { URL, URLSearchParams } from 'url';
{{#multipartFormData}}
import FormData from 'form-data'
{{/multipartFormData}}
{{/withNodeImports}}
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '{{apiRelativeToRoot}}common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from '{{apiRelativeToRoot}}base';
{{#imports}}
// @ts-ignore
import { {{classname}} } from '{{apiRelativeToRoot}}{{tsModelPackage}}';
{{/imports}}
{{/withSeparateModelsAndApi}}
{{^withSeparateModelsAndApi}}
{{/withSeparateModelsAndApi}}
{{#operations}}
/**
* {{classname}} - axios parameter creator{{#description}}
* {{&description}}{{/description}}
* @export
*/
export const {{classname}}AxiosParamCreator = function (configuration?: Configuration) {
return {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
*/
{{nickname}}: async ({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
{{#allParams}}
{{#required}}
// verify required parameter '{{paramName}}' is not null or undefined
assertParamExists('{{nickname}}', '{{paramName}}', {{paramName}})
{{/required}}
{{/allParams}}
const localVarPath = `{{{path}}}`{{#pathParams}}
.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String({{paramName}}))){{/pathParams}};
// 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: '{{httpMethod}}', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;{{#vendorExtensions}}{{#hasFormParams}}
const localVarFormParams = new {{^multipartFormData}}URLSearchParams(){{/multipartFormData}}{{#multipartFormData}}((configuration && configuration.formDataCtor) || FormData)(){{/multipartFormData}};{{/hasFormParams}}{{/vendorExtensions}}
{{#authMethods}}
// authentication {{name}} required
{{#isApiKey}}
{{#isKeyInHeader}}
await setApiKeyToObject(localVarHeaderParameter, "{{keyParamName}}", configuration)
{{/isKeyInHeader}}
{{#isKeyInQuery}}
await setApiKeyToObject(localVarQueryParameter, "{{keyParamName}}", configuration)
{{/isKeyInQuery}}
{{/isApiKey}}
{{#isBasicBasic}}
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
{{/isBasicBasic}}
{{#isBasicBearer}}
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
{{/isBasicBearer}}
{{#isOAuth}}
// oauth required
await setOAuthToObject(localVarHeaderParameter, "{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}], configuration)
{{/isOAuth}}
{{/authMethods}}
{{#queryParams}}
{{#isArray}}
if ({{paramName}}) {
{{#isCollectionFormatMulti}}
{{#uniqueItems}}
localVarQueryParameter['{{baseName}}'] = Array.from({{paramName}});
{{/uniqueItems}}
{{^uniqueItems}}
localVarQueryParameter['{{baseName}}'] = {{paramName}};
{{/uniqueItems}}
{{/isCollectionFormatMulti}}
{{^isCollectionFormatMulti}}
{{#uniqueItems}}
localVarQueryParameter['{{baseName}}'] = Array.from({{paramName}}).join(COLLECTION_FORMATS.{{collectionFormat}});
{{/uniqueItems}}
{{^uniqueItems}}
localVarQueryParameter['{{baseName}}'] = {{paramName}}.join(COLLECTION_FORMATS.{{collectionFormat}});
{{/uniqueItems}}
{{/isCollectionFormatMulti}}
}
{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined) {
{{#isDateTime}}
localVarQueryParameter['{{baseName}}'] = ({{paramName}} as any instanceof Date) ?
({{paramName}} as any).toISOString() :
{{paramName}};
{{/isDateTime}}
{{^isDateTime}}
{{#isDate}}
localVarQueryParameter['{{baseName}}'] = ({{paramName}} as any instanceof Date) ?
({{paramName}} as any).toISOString().substr(0,10) :
{{paramName}};
{{/isDate}}
{{^isDate}}
localVarQueryParameter['{{baseName}}'] = {{paramName}};
{{/isDate}}
{{/isDateTime}}
}
{{/isArray}}
{{/queryParams}}
{{#headerParams}}
{{#isArray}}
if ({{paramName}}) {
{{#uniqueItems}}
let mapped = Array.from({{paramName}}).map(value => (<any>"{{{dataType}}}" !== "Set<string>") ? JSON.stringify(value) : (value || ""));
{{/uniqueItems}}
{{^uniqueItems}}
let mapped = {{paramName}}.map(value => (<any>"{{{dataType}}}" !== "Array<string>") ? JSON.stringify(value) : (value || ""));
{{/uniqueItems}}
localVarHeaderParameter['{{baseName}}'] = mapped.join(COLLECTION_FORMATS["{{collectionFormat}}"]);
}
{{/isArray}}
{{^isArray}}
{{! `val == null` covers for both `null` and `undefined`}}
if ({{paramName}} != null) {
{{#isString}}
localVarHeaderParameter['{{baseName}}'] = String({{paramName}});
{{/isString}}
{{^isString}}
{{! isString is falsy also for $ref that defines a string or enum type}}
localVarHeaderParameter['{{baseName}}'] = typeof {{paramName}} === 'string'
? {{paramName}}
: JSON.stringify({{paramName}});
{{/isString}}
}
{{/isArray}}
{{/headerParams}}
{{#vendorExtensions}}
{{#formParams}}
{{#isArray}}
if ({{paramName}}) {
{{#isCollectionFormatMulti}}
{{paramName}}.forEach((element) => {
localVarFormParams.{{#multipartFormData}}append{{/multipartFormData}}{{^multipartFormData}}set{{/multipartFormData}}('{{baseName}}', element as any);
})
{{/isCollectionFormatMulti}}
{{^isCollectionFormatMulti}}
localVarFormParams.{{#multipartFormData}}append{{/multipartFormData}}{{^multipartFormData}}set{{/multipartFormData}}('{{baseName}}', {{paramName}}.join(COLLECTION_FORMATS.{{collectionFormat}}));
{{/isCollectionFormatMulti}}
}{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined) { {{^multipartFormData}}
localVarFormParams.set('{{baseName}}', {{paramName}} as any);{{/multipartFormData}}{{#multipartFormData}}{{#isPrimitiveType}}
localVarFormParams.append('{{baseName}}', {{paramName}} as any);{{/isPrimitiveType}}{{^isPrimitiveType}}{{#isEnum}}
localVarFormParams.append('{{baseName}}', {{paramName}} as any);{{/isEnum}}{{^isEnum}}
localVarFormParams.append('{{baseName}}', new Blob([JSON.stringify({{paramName}})], { type: "application/json", }));{{/isEnum}}{{/isPrimitiveType}}{{/multipartFormData}}
}{{/isArray}}
{{/formParams}}{{/vendorExtensions}}
{{#vendorExtensions}}{{#hasFormParams}}{{^multipartFormData}}
localVarHeaderParameter['Content-Type'] = 'application/x-www-form-urlencoded';{{/multipartFormData}}{{#multipartFormData}}
localVarHeaderParameter['Content-Type'] = 'multipart/form-data';{{/multipartFormData}}
{{/hasFormParams}}{{/vendorExtensions}}
{{#bodyParam}}
{{^consumes}}
localVarHeaderParameter['Content-Type'] = 'application/json';
{{/consumes}}
{{#consumes.0}}
localVarHeaderParameter['Content-Type'] = '{{{mediaType}}}';
{{/consumes.0}}
{{/bodyParam}}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions,{{#hasFormParams}}{{#multipartFormData}} ...(localVarFormParams as any).getHeaders?.(),{{/multipartFormData}}{{/hasFormParams}} ...options.headers};
{{#hasFormParams}}
localVarRequestOptions.data = localVarFormParams{{#vendorExtensions}}{{^multipartFormData}}.toString(){{/multipartFormData}}{{/vendorExtensions}};
{{/hasFormParams}}
{{#bodyParam}}
localVarRequestOptions.data = serializeDataIfNeeded({{paramName}}, localVarRequestOptions, configuration)
{{/bodyParam}}
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
{{/operation}}
}
};
/**
* {{classname}} - functional programming interface{{#description}}
* {{{.}}}{{/description}}
* @export
*/
export const {{classname}}Fp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = {{classname}}AxiosParamCreator(configuration)
return {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
*/
async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
{{/operation}}
}
};
/**
* {{classname}} - factory interface{{#description}}
* {{&description}}{{/description}}
* @export
*/
export const {{classname}}Factory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = {{classname}}Fp(configuration)
return {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#useSingleRequestParameter}}
{{#allParams.0}}
* @param {{=<% %>=}}{<%& classname %><%& operationIdCamelCase %>Request}<%={{ }}=%> requestParameters Request parameters.
{{/allParams.0}}
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
{{/useSingleRequestParameter}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
*/
{{#useSingleRequestParameter}}
{{nickname}}({{#allParams.0}}requestParameters: {{classname}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}options?: AxiosRequestConfig): AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}> {
return localVarFp.{{nickname}}({{#allParams.0}}{{#allParams}}requestParameters.{{paramName}}, {{/allParams}}{{/allParams.0}}options).then((request) => request(axios, basePath));
},
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}> {
return localVarFp.{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options).then((request) => request(axios, basePath));
},
{{/useSingleRequestParameter}}
{{/operation}}
};
};
{{#withInterfaces}}
/**
* {{classname}} - interface{{#description}}
* {{&description}}{{/description}}
* @export
* @interface {{classname}}
*/
export interface {{classname}}Interface {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
* @memberof {{classname}}Interface
*/
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: AxiosRequestConfig): AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}>;
{{/operation}}
}
{{/withInterfaces}}
{{#useSingleRequestParameter}}
{{#operation}}
{{#allParams.0}}
/**
* Request parameters for {{nickname}} operation in {{classname}}.
* @export
* @interface {{classname}}{{operationIdCamelCase}}Request
*/
export interface {{classname}}{{operationIdCamelCase}}Request {
{{#allParams}}
/**
* {{description}}
* @type {{=<% %>=}}{<%&dataType%>}<%={{ }}=%>
* @memberof {{classname}}{{operationIdCamelCase}}
*/
readonly {{paramName}}{{^required}}?{{/required}}: {{{dataType}}}
{{^-last}}
{{/-last}}
{{/allParams}}
}
{{/allParams.0}}
{{/operation}}
{{/useSingleRequestParameter}}
/**
* {{classname}} - object-oriented interface{{#description}}
* {{{.}}}{{/description}}
* @export
* @class {{classname}}
* @extends {BaseAPI}
*/
{{#withInterfaces}}
export class {{classname}} extends BaseAPI implements {{classname}}Interface {
{{/withInterfaces}}
{{^withInterfaces}}
export class {{classname}} extends BaseAPI {
{{/withInterfaces}}
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#useSingleRequestParameter}}
{{#allParams.0}}
* @param {{=<% %>=}}{<%& classname %><%& operationIdCamelCase %>Request}<%={{ }}=%> requestParameters Request parameters.
{{/allParams.0}}
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
{{/useSingleRequestParameter}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
* @memberof {{classname}}
*/
{{#useSingleRequestParameter}}
public {{nickname}}({{#allParams.0}}requestParameters: {{classname}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}options?: AxiosRequestConfig) {
return {{classname}}Fp(this.configuration).{{nickname}}({{#allParams.0}}{{#allParams}}requestParameters.{{paramName}}, {{/allParams}}{{/allParams.0}}options).then((request) => request(this.axios, this.basePath));
}
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
public {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: AxiosRequestConfig) {
return {{classname}}Fp(this.configuration).{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options).then((request) => request(this.axios, this.basePath));
}
{{/useSingleRequestParameter}}
{{^-last}}
{{/-last}}
{{/operation}}
}
{{/operations}}

View File

@@ -0,0 +1,390 @@
{{#withSeparateModelsAndApi}}
/* tslint:disable */
/* eslint-disable */
{{>licenseInfo}}
import type { Configuration } from '{{apiRelativeToRoot}}configuration';
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from 'axios';
import globalAxios from 'axios';
{{#withNodeImports}}
// URLSearchParams not necessarily used
// @ts-ignore
import { URL, URLSearchParams } from 'url';
{{#multipartFormData}}
import FormData from 'form-data'
{{/multipartFormData}}
{{/withNodeImports}}
// Some imports not used depending on template conditions
// @ts-ignore
import { DUMMY_BASE_URL, assertParamExists, setApiKeyToObject, setBasicAuthToObject, setBearerAuthToObject, setOAuthToObject, setSearchParams, serializeDataIfNeeded, toPathString, createRequestFunction } from '{{apiRelativeToRoot}}common';
// @ts-ignore
import { BASE_PATH, COLLECTION_FORMATS, RequestArgs, BaseAPI, RequiredError } from '{{apiRelativeToRoot}}base';
{{#imports}}
// @ts-ignore
import { {{classname}} } from '{{apiRelativeToRoot}}{{tsModelPackage}}';
{{/imports}}
{{/withSeparateModelsAndApi}}
{{^withSeparateModelsAndApi}}
{{/withSeparateModelsAndApi}}
{{#operations}}
/**
* {{classname}} - axios parameter creator{{#description}}
* {{&description}}{{/description}}
* @export
*/
export const {{classname}}AxiosParamCreator = function (configuration?: Configuration) {
return {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
*/
{{nickname}}: async ({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
{{#allParams}}
{{#required}}
// verify required parameter '{{paramName}}' is not null or undefined
assertParamExists('{{nickname}}', '{{paramName}}', {{paramName}})
{{/required}}
{{/allParams}}
const localVarPath = `{{{path}}}`{{#pathParams}}
.replace(`{${"{{baseName}}"}}`, encodeURIComponent(String({{paramName}}))){{/pathParams}};
// 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: '{{httpMethod}}', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;{{#vendorExtensions}}{{#hasFormParams}}
const localVarFormParams = new {{^multipartFormData}}URLSearchParams(){{/multipartFormData}}{{#multipartFormData}}((configuration && configuration.formDataCtor) || FormData)(){{/multipartFormData}};{{/hasFormParams}}{{/vendorExtensions}}
{{#authMethods}}
// authentication {{name}} required
{{#isApiKey}}
{{#isKeyInHeader}}
await setApiKeyToObject(localVarHeaderParameter, "{{keyParamName}}", configuration)
{{/isKeyInHeader}}
{{#isKeyInQuery}}
await setApiKeyToObject(localVarQueryParameter, "{{keyParamName}}", configuration)
{{/isKeyInQuery}}
{{/isApiKey}}
{{#isBasicBasic}}
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration)
{{/isBasicBasic}}
{{#isBasicBearer}}
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
{{/isBasicBearer}}
{{#isOAuth}}
// oauth required
await setOAuthToObject(localVarHeaderParameter, "{{name}}", [{{#scopes}}"{{{scope}}}"{{^-last}}, {{/-last}}{{/scopes}}], configuration)
{{/isOAuth}}
{{/authMethods}}
{{#queryParams}}
{{#isArray}}
if ({{paramName}}) {
{{#isCollectionFormatMulti}}
{{#uniqueItems}}
localVarQueryParameter['{{baseName}}'] = Array.from({{paramName}});
{{/uniqueItems}}
{{^uniqueItems}}
localVarQueryParameter['{{baseName}}'] = {{paramName}};
{{/uniqueItems}}
{{/isCollectionFormatMulti}}
{{^isCollectionFormatMulti}}
{{#uniqueItems}}
localVarQueryParameter['{{baseName}}'] = Array.from({{paramName}}).join(COLLECTION_FORMATS.{{collectionFormat}});
{{/uniqueItems}}
{{^uniqueItems}}
localVarQueryParameter['{{baseName}}'] = {{paramName}}.join(COLLECTION_FORMATS.{{collectionFormat}});
{{/uniqueItems}}
{{/isCollectionFormatMulti}}
}
{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined) {
{{#isDateTime}}
localVarQueryParameter['{{baseName}}'] = ({{paramName}} as any instanceof Date) ?
({{paramName}} as any).toISOString() :
{{paramName}};
{{/isDateTime}}
{{^isDateTime}}
{{#isDate}}
localVarQueryParameter['{{baseName}}'] = ({{paramName}} as any instanceof Date) ?
({{paramName}} as any).toISOString().substr(0,10) :
{{paramName}};
{{/isDate}}
{{^isDate}}
localVarQueryParameter['{{baseName}}'] = {{paramName}};
{{/isDate}}
{{/isDateTime}}
}
{{/isArray}}
{{/queryParams}}
{{#headerParams}}
{{#isArray}}
if ({{paramName}}) {
{{#uniqueItems}}
let mapped = Array.from({{paramName}}).map(value => (<any>"{{{dataType}}}" !== "Set<string>") ? JSON.stringify(value) : (value || ""));
{{/uniqueItems}}
{{^uniqueItems}}
let mapped = {{paramName}}.map(value => (<any>"{{{dataType}}}" !== "Array<string>") ? JSON.stringify(value) : (value || ""));
{{/uniqueItems}}
localVarHeaderParameter['{{baseName}}'] = mapped.join(COLLECTION_FORMATS["{{collectionFormat}}"]);
}
{{/isArray}}
{{^isArray}}
{{! `val == null` covers for both `null` and `undefined`}}
if ({{paramName}} != null) {
{{#isString}}
localVarHeaderParameter['{{baseName}}'] = String({{paramName}});
{{/isString}}
{{^isString}}
{{! isString is falsy also for $ref that defines a string or enum type}}
localVarHeaderParameter['{{baseName}}'] = typeof {{paramName}} === 'string'
? {{paramName}}
: JSON.stringify({{paramName}});
{{/isString}}
}
{{/isArray}}
{{/headerParams}}
{{#vendorExtensions}}
{{#formParams}}
{{#isArray}}
if ({{paramName}}) {
{{#isCollectionFormatMulti}}
{{paramName}}.forEach((element) => {
localVarFormParams.{{#multipartFormData}}append{{/multipartFormData}}{{^multipartFormData}}set{{/multipartFormData}}('{{baseName}}', element as any);
})
{{/isCollectionFormatMulti}}
{{^isCollectionFormatMulti}}
localVarFormParams.{{#multipartFormData}}append{{/multipartFormData}}{{^multipartFormData}}set{{/multipartFormData}}('{{baseName}}', {{paramName}}.join(COLLECTION_FORMATS.{{collectionFormat}}));
{{/isCollectionFormatMulti}}
}{{/isArray}}
{{^isArray}}
if ({{paramName}} !== undefined) { {{^multipartFormData}}
localVarFormParams.set('{{baseName}}', {{paramName}} as any);{{/multipartFormData}}{{#multipartFormData}}{{#isPrimitiveType}}
localVarFormParams.append('{{baseName}}', {{paramName}} as any);{{/isPrimitiveType}}{{^isPrimitiveType}}
localVarFormParams.append('{{baseName}}', new Blob([JSON.stringify({{paramName}})], { type: "application/json", }));{{/isPrimitiveType}}{{/multipartFormData}}
}{{/isArray}}
{{/formParams}}{{/vendorExtensions}}
{{#vendorExtensions}}{{#hasFormParams}}{{^multipartFormData}}
localVarHeaderParameter['Content-Type'] = 'application/x-www-form-urlencoded';{{/multipartFormData}}{{#multipartFormData}}
localVarHeaderParameter['Content-Type'] = 'multipart/form-data';{{/multipartFormData}}
{{/hasFormParams}}{{/vendorExtensions}}
{{#bodyParam}}
{{^consumes}}
localVarHeaderParameter['Content-Type'] = 'application/json';
{{/consumes}}
{{#consumes.0}}
localVarHeaderParameter['Content-Type'] = '{{{mediaType}}}';
{{/consumes.0}}
{{/bodyParam}}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions,{{#hasFormParams}}{{#multipartFormData}} ...(localVarFormParams as any).getHeaders?.(),{{/multipartFormData}}{{/hasFormParams}} ...options.headers};
{{#hasFormParams}}
localVarRequestOptions.data = localVarFormParams{{#vendorExtensions}}{{^multipartFormData}}.toString(){{/multipartFormData}}{{/vendorExtensions}};
{{/hasFormParams}}
{{#bodyParam}}
localVarRequestOptions.data = serializeDataIfNeeded({{paramName}}, localVarRequestOptions, configuration)
{{/bodyParam}}
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
{{/operation}}
}
};
/**
* {{classname}} - functional programming interface{{#description}}
* {{{.}}}{{/description}}
* @export
*/
export const {{classname}}Fp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = {{classname}}AxiosParamCreator(configuration)
return {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
*/
async {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
{{/operation}}
}
};
/**
* {{classname}} - factory interface{{#description}}
* {{&description}}{{/description}}
* @export
*/
export const {{classname}}Factory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = {{classname}}Fp(configuration)
return {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#useSingleRequestParameter}}
{{#allParams.0}}
* @param {{=<% %>=}}{<%& classname %><%& operationIdCamelCase %>Request}<%={{ }}=%> requestParameters Request parameters.
{{/allParams.0}}
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
{{/useSingleRequestParameter}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
*/
{{#useSingleRequestParameter}}
{{nickname}}({{#allParams.0}}requestParameters: {{classname}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}options?: AxiosRequestConfig): AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}> {
return localVarFp.{{nickname}}({{#allParams.0}}{{#allParams}}requestParameters.{{paramName}}, {{/allParams}}{{/allParams.0}}options).then((request) => request(axios, basePath));
},
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: any): AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}> {
return localVarFp.{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options).then((request) => request(axios, basePath));
},
{{/useSingleRequestParameter}}
{{/operation}}
};
};
{{#withInterfaces}}
/**
* {{classname}} - interface{{#description}}
* {{&description}}{{/description}}
* @export
* @interface {{classname}}
*/
export interface {{classname}}Interface {
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
* @memberof {{classname}}Interface
*/
{{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: AxiosRequestConfig): AxiosPromise<{{{returnType}}}{{^returnType}}void{{/returnType}}>;
{{/operation}}
}
{{/withInterfaces}}
{{#useSingleRequestParameter}}
{{#operation}}
{{#allParams.0}}
/**
* Request parameters for {{nickname}} operation in {{classname}}.
* @export
* @interface {{classname}}{{operationIdCamelCase}}Request
*/
export interface {{classname}}{{operationIdCamelCase}}Request {
{{#allParams}}
/**
* {{description}}
* @type {{=<% %>=}}{<%&dataType%>}<%={{ }}=%>
* @memberof {{classname}}{{operationIdCamelCase}}
*/
readonly {{paramName}}{{^required}}?{{/required}}: {{{dataType}}}
{{^-last}}
{{/-last}}
{{/allParams}}
}
{{/allParams.0}}
{{/operation}}
{{/useSingleRequestParameter}}
/**
* {{classname}} - object-oriented interface{{#description}}
* {{{.}}}{{/description}}
* @export
* @class {{classname}}
* @extends {BaseAPI}
*/
{{#withInterfaces}}
export class {{classname}} extends BaseAPI implements {{classname}}Interface {
{{/withInterfaces}}
{{^withInterfaces}}
export class {{classname}} extends BaseAPI {
{{/withInterfaces}}
{{#operation}}
/**
* {{&notes}}
{{#summary}}
* @summary {{&summary}}
{{/summary}}
{{#useSingleRequestParameter}}
{{#allParams.0}}
* @param {{=<% %>=}}{<%& classname %><%& operationIdCamelCase %>Request}<%={{ }}=%> requestParameters Request parameters.
{{/allParams.0}}
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
{{#allParams}}
* @param {{=<% %>=}}{<%&dataType%>}<%={{ }}=%> {{^required}}[{{/required}}{{paramName}}{{^required}}]{{/required}} {{description}}
{{/allParams}}
{{/useSingleRequestParameter}}
* @param {*} [options] Override http request option.{{#isDeprecated}}
* @deprecated{{/isDeprecated}}
* @throws {RequiredError}
* @memberof {{classname}}
*/
{{#useSingleRequestParameter}}
public {{nickname}}({{#allParams.0}}requestParameters: {{classname}}{{operationIdCamelCase}}Request{{^hasRequiredParams}} = {}{{/hasRequiredParams}}, {{/allParams.0}}options?: AxiosRequestConfig) {
return {{classname}}Fp(this.configuration).{{nickname}}({{#allParams.0}}{{#allParams}}requestParameters.{{paramName}}, {{/allParams}}{{/allParams.0}}options).then((request) => request(this.axios, this.basePath));
}
{{/useSingleRequestParameter}}
{{^useSingleRequestParameter}}
public {{nickname}}({{#allParams}}{{paramName}}{{^required}}?{{/required}}: {{{dataType}}}, {{/allParams}}options?: AxiosRequestConfig) {
return {{classname}}Fp(this.configuration).{{nickname}}({{#allParams}}{{paramName}}, {{/allParams}}options).then((request) => request(this.axios, this.basePath));
}
{{/useSingleRequestParameter}}
{{^-last}}
{{/-last}}
{{/operation}}
}
{{/operations}}

View File

@@ -0,0 +1,14 @@
--- apiInner.mustache 2023-02-10 17:44:20.945845049 +0000
+++ apiInner.mustache.patch 2023-02-10 17:46:28.669054112 +0000
@@ -173,8 +173,9 @@
{{^isArray}}
if ({{paramName}} !== undefined) { {{^multipartFormData}}
localVarFormParams.set('{{baseName}}', {{paramName}} as any);{{/multipartFormData}}{{#multipartFormData}}{{#isPrimitiveType}}
- localVarFormParams.append('{{baseName}}', {{paramName}} as any);{{/isPrimitiveType}}{{^isPrimitiveType}}
- localVarFormParams.append('{{baseName}}', new Blob([JSON.stringify({{paramName}})], { type: "application/json", }));{{/isPrimitiveType}}{{/multipartFormData}}
+ localVarFormParams.append('{{baseName}}', {{paramName}} as any);{{/isPrimitiveType}}{{^isPrimitiveType}}{{#isEnum}}
+ localVarFormParams.append('{{baseName}}', {{paramName}} as any);{{/isEnum}}{{^isEnum}}
+ localVarFormParams.append('{{baseName}}', new Blob([JSON.stringify({{paramName}})], { type: "application/json", }));{{/isEnum}}{{/isPrimitiveType}}{{/multipartFormData}}
}{{/isArray}}
{{/formParams}}{{/vendorExtensions}}
{{#vendorExtensions}}{{#hasFormParams}}{{^multipartFormData}}

View File

@@ -1,12 +1,12 @@
{
"name": "immich",
"version": "1.65.0",
"version": "1.66.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "immich",
"version": "1.65.0",
"version": "1.66.1",
"license": "UNLICENSED",
"dependencies": {
"@babel/runtime": "^7.20.13",

View File

@@ -1,6 +1,6 @@
{
"name": "immich",
"version": "1.65.0",
"version": "1.66.1",
"description": "",
"author": "",
"private": true,

View File

@@ -191,6 +191,12 @@ describe(FacialRecognitionService.name, () => {
personId: 'person-1',
assetId: 'asset-id',
embedding: [1, 2, 3, 4],
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
});
});
@@ -207,6 +213,12 @@ describe(FacialRecognitionService.name, () => {
personId: 'person-1',
assetId: 'asset-id',
embedding: [1, 2, 3, 4],
boundingBoxX1: 100,
boundingBoxY1: 100,
boundingBoxX2: 200,
boundingBoxY2: 200,
imageHeight: 500,
imageWidth: 400,
});
expect(jobMock.queue.mock.calls).toEqual([
[

View File

@@ -83,7 +83,16 @@ export class FacialRecognitionService {
const faceId: AssetFaceId = { assetId: asset.id, personId };
await this.faceRepository.create({ ...faceId, embedding });
await this.faceRepository.create({
...faceId,
embedding,
imageHeight: rest.imageHeight,
imageWidth: rest.imageWidth,
boundingBoxX1: rest.boundingBox.x1,
boundingBoxX2: rest.boundingBox.x2,
boundingBoxY1: rest.boundingBox.y1,
boundingBoxY2: rest.boundingBox.y2,
});
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_FACE, data: faceId });
}

View File

@@ -1,6 +1,7 @@
import { SystemConfig } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
assetEntityStub,
asyncTick,
newAssetRepositoryMock,
newCommunicationRepositoryMock,
@@ -271,6 +272,17 @@ describe(JobService.name, () => {
JobName.GENERATE_THUMBHASH_THUMBNAIL,
],
},
{
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,
JobName.VIDEO_CONVERSION,
],
},
{
item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } },
jobs: [JobName.SEARCH_INDEX_ASSET],
@@ -287,7 +299,11 @@ describe(JobService.name, () => {
for (const { item, jobs } of tests) {
it(`should queue ${jobs.length} jobs when a ${item.name} job finishes successfully`, async () => {
assetMock.getByIds.mockResolvedValue([]);
if (item.name === JobName.GENERATE_JPEG_THUMBNAIL && item.data.source === 'upload') {
assetMock.getByIds.mockResolvedValue([assetEntityStub.livePhotoMotionAsset]);
} else {
assetMock.getByIds.mockResolvedValue([]);
}
await sut.registerHandlers(makeMockHandlers(true));
await jobMock.addHandler.mock.calls[0][2](item);

View File

@@ -1,3 +1,4 @@
import { AssetType } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { IAssetRepository, mapAsset } from '../asset';
import { CommunicationEvent, ICommunicationRepository } from '../communication';
@@ -163,9 +164,15 @@ export class JobService {
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 });
if (item.data.source !== 'upload') {
break;
}
const [asset] = await this.assetRepository.getByIds([item.data.id]);
if (asset) {
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: item.data });
}
this.communicationRepository.send(CommunicationEvent.UPLOAD_SUCCESS, asset.ownerId, mapAsset(asset));
}
break;

View File

@@ -128,7 +128,7 @@ export class MediaService {
async handleVideoConversion({ id }: IEntityJob) {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset) {
if (!asset || asset.type !== AssetType.VIDEO) {
return false;
}

View File

@@ -1,10 +1,20 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { IsNotEmpty, IsString } from 'class-validator';
import { IsOptional, IsString } from 'class-validator';
export class PersonUpdateDto {
@IsNotEmpty()
/**
* Person name.
*/
@IsOptional()
@IsString()
name!: string;
name?: string;
/**
* Asset is used to get the feature face thumbnail.
*/
@IsOptional()
@IsString()
featureFaceAssetId?: string;
}
export class PersonResponseDto {

View File

@@ -1,5 +1,5 @@
import { AssetEntity, PersonEntity } from '@app/infra/entities';
import { AssetFaceId } from '@app/domain';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '@app/infra/entities';
export const IPersonRepository = 'IPersonRepository';
export interface PersonSearchOptions {
@@ -16,4 +16,6 @@ export interface IPersonRepository {
update(entity: Partial<PersonEntity>): Promise<PersonEntity>;
delete(entity: PersonEntity): Promise<PersonEntity | null>;
deleteAll(): Promise<number>;
getFaceById(payload: AssetFaceId): Promise<AssetFaceEntity | null>;
}

View File

@@ -2,6 +2,7 @@ import { BadRequestException, NotFoundException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
faceStub,
newJobRepositoryMock,
newPersonRepositoryMock,
newStorageRepositoryMock,
@@ -108,6 +109,36 @@ describe(PersonService.name, () => {
data: { ids: [assetEntityStub.image.id] },
});
});
it("should update a person's thumbnailPath", async () => {
personMock.getById.mockResolvedValue(personStub.withName);
personMock.getFaceById.mockResolvedValue(faceStub.face1);
await expect(
sut.update(authStub.admin, 'person-1', { featureFaceAssetId: faceStub.face1.assetId }),
).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
expect(personMock.getFaceById).toHaveBeenCalledWith({
assetId: faceStub.face1.assetId,
personId: 'person-1',
});
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
assetId: faceStub.face1.assetId,
personId: 'person-1',
boundingBox: {
x1: faceStub.face1.boundingBoxX1,
x2: faceStub.face1.boundingBoxX2,
y1: faceStub.face1.boundingBoxY1,
y2: faceStub.face1.boundingBoxY2,
},
imageHeight: faceStub.face1.imageHeight,
imageWidth: faceStub.face1.imageWidth,
},
});
});
});
describe('handlePersonCleanup', () => {

View File

@@ -1,3 +1,4 @@
import { PersonEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { AssetResponseDto, mapAsset } from '../asset';
import { AuthUserDto } from '../auth';
@@ -52,18 +53,54 @@ export class PersonService {
}
async update(authUser: AuthUserDto, personId: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
const exists = await this.repository.getById(authUser.id, personId);
if (!exists) {
let person = await this.repository.getById(authUser.id, personId);
if (!person) {
throw new BadRequestException();
}
const person = await this.repository.update({ id: personId, name: dto.name });
if (dto.name) {
person = await this.updateName(authUser, personId, dto.name);
}
if (dto.featureFaceAssetId) {
await this.updateFaceThumbnail(personId, dto.featureFaceAssetId);
}
return mapPerson(person);
}
private async updateName(authUser: AuthUserDto, personId: string, name: string): Promise<PersonEntity> {
const person = await this.repository.update({ id: personId, name });
const relatedAsset = await this.getAssets(authUser, personId);
const assetIds = relatedAsset.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids: assetIds } });
return mapPerson(person);
return person;
}
private async updateFaceThumbnail(personId: string, assetId: string): Promise<void> {
const face = await this.repository.getFaceById({ assetId, personId });
if (!face) {
throw new BadRequestException();
}
return await this.jobRepository.queue({
name: JobName.GENERATE_FACE_THUMBNAIL,
data: {
assetId: assetId,
personId,
boundingBox: {
x1: face.boundingBoxX1,
x2: face.boundingBoxX2,
y1: face.boundingBoxY1,
y2: face.boundingBoxY2,
},
imageHeight: face.imageHeight,
imageWidth: face.imageWidth,
},
});
}
async handlePersonCleanup() {

View File

@@ -19,7 +19,7 @@ import {
ValidationPipe,
} from '@nestjs/common';
import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiConsumes, ApiHeader, ApiTags } from '@nestjs/swagger';
import { Response as Res } from 'express';
import { Authenticated, AuthUser, SharedLinkRoute } from '../../app.guard';
import { assetUploadOption, ImmichFile } from '../../config/asset-upload.config';
@@ -122,7 +122,6 @@ export class AssetController {
@SharedLinkRoute()
@Get('/file/:id')
@Header('Cache-Control', 'private, max-age=86400, no-transform')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
serveFile(
@AuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,
@@ -136,7 +135,6 @@ export class AssetController {
@SharedLinkRoute()
@Get('/thumbnail/:id')
@Header('Cache-Control', 'private, max-age=86400, no-transform')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
getAssetThumbnail(
@AuthUser() authUser: AuthUserDto,
@Headers() headers: Record<string, string>,

View File

@@ -1,5 +1,5 @@
import { AuthUserDto, IJobRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, UserEntity } from '@app/infra/entities';
import { AssetEntity, UserEntity } from '@app/infra/entities';
import { parse } from 'node:path';
import { IAssetRepository } from './asset-repository';
import { CreateAssetDto, ImportAssetDto, UploadFile } from './dto/create-asset.dto';
@@ -46,9 +46,6 @@ export class AssetCore {
});
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: asset.id, source: 'upload' } });
if (asset.type === AssetType.VIDEO) {
await this.jobRepository.queue({ name: JobName.VIDEO_CONVERSION, data: { id: asset.id } });
}
return asset;
}

View File

@@ -228,7 +228,6 @@ describe('AssetService', () => {
data: { id: assetEntityStub.livePhotoMotionAsset.id, source: 'upload' },
},
],
[{ name: JobName.VIDEO_CONVERSION, data: { id: assetEntityStub.livePhotoMotionAsset.id } }],
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetEntityStub.livePhotoStillAsset.id, source: 'upload' } }],
]);
});

View File

@@ -21,16 +21,15 @@ import {
InternalServerErrorException,
Logger,
NotFoundException,
StreamableFile,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Response as Res } from 'express';
import { constants, createReadStream, stat } from 'fs';
import { constants, createReadStream } from 'fs';
import fs from 'fs/promises';
import mime from 'mime-types';
import path from 'path';
import { pipeline } from 'stream/promises';
import { QueryFailedError, Repository } from 'typeorm';
import { promisify } from 'util';
import { IAssetRepository } from './asset-repository';
import { AssetCore } from './asset.core';
import { AssetBulkUploadCheckDto } from './dto/asset-check.dto';
@@ -63,8 +62,6 @@ import { CuratedLocationsResponseDto } from './response-dto/curated-locations-re
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
import { DeleteAssetResponseDto, DeleteAssetStatusEnum } from './response-dto/delete-asset-response.dto';
const fileInfo = promisify(stat);
interface ServableFile {
filepath: string;
contentType: string;
@@ -259,11 +256,11 @@ export class AssetService {
}
try {
const thumbnailPath = this.getThumbnailPath(asset, query.format);
return this.streamFile(thumbnailPath, res, headers);
const [thumbnailPath, contentType] = this.getThumbnailPath(asset, query.format);
return this.streamFile(thumbnailPath, res, headers, contentType);
} catch (e) {
res.header('Cache-Control', 'none');
Logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
this.logger.error(`Cannot create read stream for asset ${asset.id}`, 'getAssetThumbnail');
throw new InternalServerErrorException(
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
{ cause: e as Error },
@@ -294,7 +291,7 @@ export class AssetService {
const { filepath, contentType } = this.getServePath(asset, query, allowOriginalFile);
return this.streamFile(filepath, res, headers, contentType);
} catch (e) {
Logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]');
this.logger.error(`Cannot create read stream for asset ${asset.id} ${JSON.stringify(e)}`, 'serveFile[IMAGE]');
throw new InternalServerErrorException(
e,
`Cannot read thumbnail file for asset ${asset.id} - contact your administrator`,
@@ -302,56 +299,8 @@ export class AssetService {
}
} else {
try {
// Handle Video
let videoPath = asset.originalPath;
let mimeType = asset.mimeType;
await fs.access(videoPath, constants.R_OK);
if (asset.encodedVideoPath) {
videoPath = asset.encodedVideoPath == '' ? String(asset.originalPath) : String(asset.encodedVideoPath);
mimeType = asset.encodedVideoPath == '' ? asset.mimeType : 'video/mp4';
}
const { size } = await fileInfo(videoPath);
const range = headers.range;
if (range) {
/** Extracting Start and End value from Range Header */
const [startStr, endStr] = range.replace(/bytes=/, '').split('-');
let start = parseInt(startStr, 10);
let end = endStr ? parseInt(endStr, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
// Return the 416 Range Not Satisfiable.
res.status(416).set({ 'Content-Range': `bytes */${size}` });
throw new BadRequestException('Bad Request Range');
}
/** Sending Partial Content With HTTP Code 206 */
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
'Content-Type': mimeType,
});
const videoStream = createReadStream(videoPath, { start, end });
return new StreamableFile(videoStream);
}
const videoPath = asset.encodedVideoPath ? asset.encodedVideoPath : asset.originalPath;
const mimeType = asset.encodedVideoPath ? 'video/mp4' : asset.mimeType;
return this.streamFile(videoPath, res, headers, mimeType);
} catch (e: Error | any) {
@@ -573,16 +522,17 @@ export class AssetService {
private getThumbnailPath(asset: AssetEntity, format: GetAssetThumbnailFormatEnum) {
switch (format) {
case GetAssetThumbnailFormatEnum.WEBP:
if (asset.webpPath && asset.webpPath.length > 0) {
return asset.webpPath;
if (asset.webpPath) {
return [asset.webpPath, 'image/webp'];
}
this.logger.warn(`WebP thumbnail requested but not found for asset ${asset.id}, falling back to JPEG`);
case GetAssetThumbnailFormatEnum.JPEG:
default:
if (!asset.resizePath) {
throw new NotFoundException('resizePath not set');
throw new NotFoundException(`No thumbnail found for asset ${asset.id}`);
}
return asset.resizePath;
return [asset.resizePath, 'image/jpeg'];
}
}
@@ -618,12 +568,16 @@ export class AssetService {
}
private async streamFile(filepath: string, res: Res, headers: Record<string, string>, contentType?: string | null) {
await fs.access(filepath, constants.R_OK);
const { size, mtimeNs } = await fs.stat(filepath, { bigint: true });
if (contentType) {
res.header('Content-Type', contentType);
}
const range = this.setResRange(res, headers, Number(size));
// etag
const { size, mtimeNs } = await fs.stat(filepath, { bigint: true });
const etag = `W/"${size}-${mtimeNs}"`;
res.setHeader('ETag', etag);
if (etag === headers['if-none-match']) {
@@ -631,8 +585,48 @@ export class AssetService {
return;
}
await fs.access(filepath, constants.R_OK);
const stream = createReadStream(filepath, range);
return await pipeline(stream, res).catch((err) => {
if (err.code !== 'ERR_STREAM_PREMATURE_CLOSE') {
this.logger.error(err);
}
});
}
return new StreamableFile(createReadStream(filepath));
private setResRange(res: Res, headers: Record<string, string>, size: number) {
if (!headers.range) {
return {};
}
/** Extracting Start and End value from Range Header */
const [startStr, endStr] = headers.range.replace(/bytes=/, '').split('-');
let start = parseInt(startStr, 10);
let end = endStr ? parseInt(endStr, 10) : size - 1;
if (!isNaN(start) && isNaN(end)) {
start = start;
end = size - 1;
}
if (isNaN(start) && !isNaN(end)) {
start = size - end;
end = size - 1;
}
// Handle unavailable range request
if (start >= size || end >= size) {
console.error('Bad Request');
res.status(416).set({ 'Content-Range': `bytes */${size}` });
throw new BadRequestException('Bad Request Range');
}
res.status(206).set({
'Content-Range': `bytes ${start}-${end}/${size}`,
'Accept-Ranges': 'bytes',
'Content-Length': end - start + 1,
});
return { start, end };
}
}

View File

@@ -1,5 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional } from 'class-validator';
import { IsEnum, IsOptional } from 'class-validator';
export enum GetAssetThumbnailFormatEnum {
JPEG = 'JPEG',
@@ -8,6 +8,7 @@ export enum GetAssetThumbnailFormatEnum {
export class GetAssetThumbnailDto {
@IsOptional()
@IsEnum(GetAssetThumbnailFormatEnum)
@ApiProperty({
type: String,
enum: GetAssetThumbnailFormatEnum,

View File

@@ -7,7 +7,7 @@ import {
PersonUpdateDto,
} from '@app/domain';
import { Body, Controller, Get, Param, Put, StreamableFile } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiTags } from '@nestjs/swagger';
import { Authenticated, AuthUser } from '../app.guard';
import { UseValidation } from '../app.utils';
import { UUIDParamDto } from './dto/uuid-param.dto';
@@ -43,7 +43,6 @@ export class PersonController {
}
@Get(':id/thumbnail')
@ApiOkResponse({ content: { 'application/octet-stream': { schema: { type: 'string', format: 'binary' } } } })
getPersonThumbnail(@AuthUser() authUser: AuthUserDto, @Param() { id }: UUIDParamDto) {
return this.service.getThumbnail(authUser, id).then(asStreamableFile);
}

View File

@@ -17,6 +17,24 @@ export class AssetFaceEntity {
})
embedding!: number[] | null;
@Column({ default: 0, type: 'int' })
imageWidth!: number;
@Column({ default: 0, type: 'int' })
imageHeight!: number;
@Column({ default: 0, type: 'int' })
boundingBoxX1!: number;
@Column({ default: 0, type: 'int' })
boundingBoxY1!: number;
@Column({ default: 0, type: 'int' })
boundingBoxX2!: number;
@Column({ default: 0, type: 'int' })
boundingBoxY2!: number;
@ManyToOne(() => AssetEntity, (asset) => asset.faces, { onDelete: 'CASCADE', onUpdate: 'CASCADE' })
asset!: AssetEntity;

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddDetectFaceResultInfo1688241394489 implements MigrationInterface {
name = 'AddDetectFaceResultInfo1688241394489';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageWidth" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "imageHeight" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX1" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY1" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxX2" integer NOT NULL DEFAULT '0'`);
await queryRunner.query(`ALTER TABLE "asset_faces" ADD "boundingBoxY2" integer NOT NULL DEFAULT '0'`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY2"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX2"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxY1"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "boundingBoxX1"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageHeight"`);
await queryRunner.query(`ALTER TABLE "asset_faces" DROP COLUMN "imageWidth"`);
}
}

View File

@@ -1,4 +1,4 @@
import { IPersonRepository, PersonSearchOptions } from '@app/domain';
import { AssetFaceId, IPersonRepository, PersonSearchOptions } from '@app/domain';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AssetEntity, AssetFaceEntity, PersonEntity } from '../entities';
@@ -77,4 +77,8 @@ export class PersonRepository implements IPersonRepository {
const { id } = await this.personRepository.save(entity);
return this.personRepository.findOneByOrFail({ id });
}
async getFaceById({ personId, assetId }: AssetFaceId): Promise<AssetFaceEntity | null> {
return this.assetFaceRepository.findOneBy({ assetId, personId });
}
}

View File

@@ -1157,6 +1157,16 @@ export const personStub = {
thumbnailPath: '',
faces: [],
}),
newThumbnail: Object.freeze<PersonEntity>({
id: 'person-1',
createdAt: new Date('2021-01-01'),
updatedAt: new Date('2021-01-01'),
ownerId: userEntityStub.admin.id,
owner: userEntityStub.admin,
name: '',
thumbnailPath: '/new/path/to/thumbnail',
faces: [],
}),
};
export const partnerStub = {
@@ -1185,6 +1195,12 @@ export const faceStub = {
personId: personStub.withName.id,
person: personStub.withName,
embedding: [1, 2, 3, 4],
boundingBoxX1: 0,
boundingBoxY1: 0,
boundingBoxX2: 1,
boundingBoxY2: 1,
imageHeight: 1024,
imageWidth: 1024,
}),
};

View File

@@ -11,5 +11,7 @@ export const newPersonRepositoryMock = (): jest.Mocked<IPersonRepository> => {
update: jest.fn(),
deleteAll: jest.fn(),
delete: jest.fn(),
getFaceById: jest.fn(),
};
};