Compare commits

...

36 Commits

Author SHA1 Message Date
Alex The Bot
1835fbae49 Version v1.68.0 2023-07-20 03:06:12 +00:00
Alex
593489a14c fix(web): cannot use shift-select (#3343) 2023-07-19 21:15:22 -05:00
Alex
9f7bf36786 fix(web): cannot use semicolon on the search bar in asset grid page (#3334)
* fix(web): cannot use semicolon on the search bar

* fix(web): cannot use semicolon on the search bar

* remove console log

* fix: disable hotkey when search is enable

* format

* fix event listener removal
2023-07-19 11:03:23 -05:00
Thomas
f0302670d2 fix(server): add missing extensions and mime types (#3318)
Add extensions and mime types which were accidentally removed in #3197.

Fixes: #3300
2023-07-19 09:27:25 -05:00
Mert
4b8cc7b533 chore: docker compose for prod build (#3333)
* added docker compose for prod build

* updated makefile
2023-07-18 23:41:02 -05:00
Jason Rasmussen
6e953ff5eb fix(server): cancel error (#3332) 2023-07-18 23:40:20 -05:00
Alex
7316ad5a72 chore(web): sort tailwindcss class automatically (#3330) 2023-07-18 13:19:39 -05:00
martin
f28fc8fa5c feat(server,web): hide faces (#3262)
* feat: hide faces

* fix: types

* pr feedback

* fix: svelte checks

* feat: new server endpoint

* refactor: rename person count dto

* fix(server): linter

* fix: remove duplicate button

* docs: add comments

* pr feedback

* fix: get unhidden faces

* fix: do not use PersonCountResponseDto

* fix: transition

* pr feedback

* pr feedback

* fix: remove unused check

* add server tests

* rename persons to people

* feat: add exit button

* pr feedback

* add server tests

* pr feedback

* pr feedback

* fix: show & hide faces

* simplify

* fix: close button

* pr feeback

* pr feeback

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-18 13:09:43 -05:00
Alex
02b70e693c feat(web): add better face management UI action (#3328)
* add better face management menu

* context menu

* change name form

* change name

* navigate to merge face

* fix web
2023-07-18 12:36:20 -05:00
bo0tzz
b2e06477f8 chore: Enable logging on typesense container (#3326) 2023-07-18 11:19:16 -05:00
martin
632971a2ac fix: allow edit to empty name (#3322) 2023-07-17 21:20:28 -05:00
Thomas
8045fd3f14 fix(web): remove dependency on rxjs (#3301)
The dependency on rxjs has been removed in favour of iterators as it's clearer
and the nature of the workload is inherently non-reactive. The uncaught error
when the list of files is empty has also been implicitly fixed by this change.

Fixes: #3300
2023-07-17 11:22:29 -05:00
Alex
a2568f711f chore(mobile): remove things sections (#3309)
Co-authored-by: Alex Tran <Alex.Tran@conductix.com>
2023-07-17 11:20:05 -05:00
martin
f9032866e7 feat(web): new shortcuts (#3111)
* feat: shortcuts

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* fix: remove listener on component destroy

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* revert delete shortcut

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* feat: new notifications

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

* fix: use handleError

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>

---------

Signed-off-by: martabal <74269598+martabal@users.noreply.github.com>
2023-07-16 22:16:14 -05:00
Adam Cigánek
e287b18435 fix(mobile): fix forgetting backup albums (#3108) (#3244) 2023-07-17 03:08:58 +00:00
oddlama
c415ee82d1 chore: adjust loglevel of reverse geocoding intializer to LOG (#3303) 2023-07-16 21:57:20 -05:00
KailashGanesh
c8f1a15f21 fix(web): adjusted offset value to match header height (#3302) 2023-07-16 19:23:01 +00:00
Dhrumil Shah
9012cf6946 fix(mobile) - Allow sign out if server is down, or device is offline (#3275)
* WIP: Allow app sign out when server cannot be reached

* WIP: import logging lib

* WIP: move log out up
2023-07-15 20:52:41 -05:00
faupau
7595d01956 feat(web): set asset as profile picture (#3106)
* add profile-image-cropper component

* add dom-to-image library

* add store to update user profile picture when set

* dom-to-image

* remove console.logs, add svelte binding

* fix format, unused vars

* change caching of profile image

* set hash after profile image change

* remove unnecessary store

* remove unecesarry changes

* set types/dom-to-image as devDependency

* remove unecessary type declarations
use handleError

* remove error notification
which is already handled by handleError

* Revert "set types/dom-to-image as devDependency"

This reverts commit ca8b3ed1bb.

* add types do dev dependencies

* use on:close instead of on:close={()=>...}

* add newline

* sort imports

* bind photo-viewer imgElement directly, not working

* remove console.log, fix binding

* make imgElement optional

* fix element as optional prop

* fix type

* check for transparency

* small changes

* fix img.decode

* add bg, remove publicsharedkey

* fix omit publicSharedKey

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-15 20:31:33 -05:00
Sergey Kondrikov
ed3c239b7e fix(web): navigation buttons z-order (#3286)
* Fix navigation styling

* z-index
* refactor transition and hover

* Add NavigationButton and NavigationArea components

* Use group-hover to simplify hover styling

* fix check

* fix check

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-07-15 20:25:59 -05:00
Alex
c254a04aec feat(server): add endpoint to get supported media types on the server (#3284)
* feat(server): add endpoint to get supported media types on the server

* api generation

* remove xmp format

* change dto

* openapi

* dev
2023-07-15 20:24:46 -05:00
Alex
d5b96c0257 chore(web): Update to Svelte 4 (#3196)
* trying to update to svelte 4

* update dependencies

* remove global transition

* suppress wrning

* chore: install from github

* revert material icon change

* Supress a11y warning

* update

* remove coverage test on web

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-07-15 20:13:04 -05:00
Alex
436a2e9bf3 chore(mobile): share logo platform consistency (#3290) 2023-07-15 20:11:51 -05:00
Brian Di Palma
b34f4345e1 Update _storage-template.md (#3291) 2023-07-15 17:27:27 -05:00
Jason Rasmussen
f55d63fae8 feat(server): storage label claim (#3278)
* feat: storage label claim

* chore: open api
2023-07-15 14:50:29 -05:00
abhi-chakrab
ed594c1987 Update start.sh (#3282)
Adding ability to use docker secrets file for REDIS_PASSWORD
2023-07-15 10:30:52 -05:00
Skkay
ab85dd9fa8 chore(docs): Remove a duplicate word (#3285) 2023-07-15 10:06:34 -05:00
Jason Rasmussen
08c7054845 refactor(server): auth/oauth (#3242)
* refactor(server): auth/oauth

* fix: show server error message on login failure
2023-07-14 23:03:56 -05:00
Harry Tran
9ef41bf1c7 fix(web): update style of rows in user administration table (#3277) 2023-07-14 22:38:16 -05:00
Jason Rasmussen
1064128fde refactor(server): upload config (#3252) 2023-07-14 20:31:42 -05:00
Jason Rasmussen
382341f550 feat(web): show download size (#3270)
* feat(web): show download size

* chore: never over 100%

* chore: use percentage

* fix: unselect assets before download finishes
2023-07-14 20:25:13 -05:00
Alex The Bot
81e07fda08 Version v1.67.2 2023-07-14 14:01:20 +00:00
Jason Rasmussen
4c4435bc19 fix: serve absolute urls (#3265) 2023-07-14 08:58:14 -05:00
Jason Rasmussen
f952bc0b64 refactor(server): asset stats (#3253)
* refactor(server): asset stats

* chore: open api
2023-07-14 08:30:17 -05:00
Jason Rasmussen
05e1a6d949 chore: hide auto generated cli content (#3254) 2023-07-14 08:22:38 -05:00
xpwmaosldk
ea3d01ec62 chore(mobile): clean up (#3256) 2023-07-14 08:20:04 -05:00
259 changed files with 24858 additions and 23406 deletions

12
.gitattributes vendored
View File

@@ -2,12 +2,16 @@ mobile/openapi/**/*.md -diff -merge
mobile/openapi/**/*.md linguist-generated=true
mobile/openapi/**/*.dart -diff -merge
mobile/openapi/**/*.dart linguist-generated=true
mobile/openapi/.openapi-generator/FILES -diff -merge
mobile/openapi/.openapi-generator/FILES linguist-generated=true
cli/src/api/open-api/**/*.md -diff -merge
cli/src/api/open-api/**/*.md linguist-generated=true
cli/src/api/open-api/**/*.ts -diff -merge
cli/src/api/open-api/**/*.ts linguist-generated=true
web/src/api/open-api/**/*.md -diff -merge
web/src/api/open-api/**/*.md linguist-generated=true
web/src/api/open-api/**/*.ts -diff -merge
web/src/api/open-api/**/*.ts linguist-generated=true
mobile/openapi/.openapi-generator/FILES -diff -merge
mobile/openapi/.openapi-generator/FILES linguist-generated=true

View File

@@ -136,9 +136,9 @@ jobs:
run: npm run check:typescript
if: ${{ !cancelled() }}
- name: Run unit tests & coverage
run: npm run test:cov
if: ${{ !cancelled() }}
# - name: Run unit tests & coverage
# run: npm run test:cov
# if: ${{ !cancelled() }}
mobile-unit-tests:
name: Run mobile unit tests

View File

@@ -23,10 +23,10 @@ test-e2e:
docker-compose -f ./docker/docker-compose.test.yml --env-file ./docker/.env.test -p immich-test-e2e up --renew-anon-volumes --abort-on-container-exit --exit-code-from immich-server-test --remove-orphans --build
prod:
docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans
prod-scale:
docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
docker-compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans
api:
cd ./server && npm run api:generate

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.67.1
* The version of the OpenAPI document: 1.68.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -486,43 +486,6 @@ export interface AssetCountByTimeBucketResponseDto {
*/
'buckets': Array<AssetCountByTimeBucket>;
}
/**
*
* @export
* @interface AssetCountByUserIdResponseDto
*/
export interface AssetCountByUserIdResponseDto {
/**
*
* @type {number}
* @memberof AssetCountByUserIdResponseDto
*/
'audio': number;
/**
*
* @type {number}
* @memberof AssetCountByUserIdResponseDto
*/
'photos': number;
/**
*
* @type {number}
* @memberof AssetCountByUserIdResponseDto
*/
'videos': number;
/**
*
* @type {number}
* @memberof AssetCountByUserIdResponseDto
*/
'other': number;
/**
*
* @type {number}
* @memberof AssetCountByUserIdResponseDto
*/
'total': number;
}
/**
*
* @export
@@ -724,6 +687,31 @@ export interface AssetResponseDto {
}
/**
*
* @export
* @interface AssetStatsResponseDto
*/
export interface AssetStatsResponseDto {
/**
*
* @type {number}
* @memberof AssetStatsResponseDto
*/
'images': number;
/**
*
* @type {number}
* @memberof AssetStatsResponseDto
*/
'videos': number;
/**
*
* @type {number}
* @memberof AssetStatsResponseDto
*/
'total': number;
}
/**
*
* @export
@@ -1789,6 +1777,31 @@ export interface OAuthConfigResponseDto {
*/
'autoLaunch'?: boolean;
}
/**
*
* @export
* @interface PeopleResponseDto
*/
export interface PeopleResponseDto {
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'total': number;
/**
*
* @type {number}
* @memberof PeopleResponseDto
*/
'visible': number;
/**
*
* @type {Array<PersonResponseDto>}
* @memberof PeopleResponseDto
*/
'people': Array<PersonResponseDto>;
}
/**
*
* @export
@@ -1813,6 +1826,12 @@ export interface PersonResponseDto {
* @memberof PersonResponseDto
*/
'thumbnailPath': string;
/**
*
* @type {boolean}
* @memberof PersonResponseDto
*/
'isHidden': boolean;
}
/**
*
@@ -1832,6 +1851,12 @@ export interface PersonUpdateDto {
* @memberof PersonUpdateDto
*/
'featureFaceAssetId'?: string;
/**
* Person visibility
* @type {boolean}
* @memberof PersonUpdateDto
*/
'isHidden'?: boolean;
}
/**
*
@@ -2097,6 +2122,31 @@ export interface ServerInfoResponseDto {
*/
'diskAvailable': string;
}
/**
*
* @export
* @interface ServerMediaTypesResponseDto
*/
export interface ServerMediaTypesResponseDto {
/**
*
* @type {Array<string>}
* @memberof ServerMediaTypesResponseDto
*/
'video': Array<string>;
/**
*
* @type {Array<string>}
* @memberof ServerMediaTypesResponseDto
*/
'image': Array<string>;
/**
*
* @type {Array<string>}
* @memberof ServerMediaTypesResponseDto
*/
'sidecar': Array<string>;
}
/**
*
* @export
@@ -2608,6 +2658,12 @@ export interface SystemConfigOAuthDto {
* @memberof SystemConfigOAuthDto
*/
'scope': string;
/**
*
* @type {string}
* @memberof SystemConfigOAuthDto
*/
'storageLabelClaim': string;
/**
*
* @type {string}
@@ -4892,44 +4948,6 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getArchivedAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/stat/archive`;
// 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};
@@ -5079,8 +5097,8 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAssetCountByUserId: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/count-by-user-id`;
getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/search-terms`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
@@ -5114,11 +5132,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
},
/**
*
* @param {boolean} [isArchived]
* @param {boolean} [isFavorite]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAssetSearchTerms: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/search-terms`;
getAssetStats: async (isArchived?: boolean, isFavorite?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/asset/statistics`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
@@ -5139,6 +5159,14 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (isArchived !== undefined) {
localVarQueryParameter['isArchived'] = isArchived;
}
if (isFavorite !== undefined) {
localVarQueryParameter['isFavorite'] = isFavorite;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -5887,15 +5915,6 @@ export const AssetApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllAssets(userId, isFavorite, isArchived, withoutThumbs, skip, ifNoneMatch, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getArchivedAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByUserIdResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getArchivedAssetCountByUserId(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
* Get a single asset\'s information
* @param {string} id
@@ -5932,17 +5951,19 @@ export const AssetApiFp = function(configuration?: Configuration) {
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAssetCountByUserId(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetCountByUserIdResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetCountByUserId(options);
async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<string>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {boolean} [isArchived]
* @param {boolean} [isFavorite]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAssetSearchTerms(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<string>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetSearchTerms(options);
async getAssetStats(isArchived?: boolean, isFavorite?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<AssetStatsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAssetStats(isArchived, isFavorite, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -6160,14 +6181,6 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
getAllAssets(requestParameters: AssetApiGetAllAssetsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<Array<AssetResponseDto>> {
return localVarFp.getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getArchivedAssetCountByUserId(options?: AxiosRequestConfig): AxiosPromise<AssetCountByUserIdResponseDto> {
return localVarFp.getArchivedAssetCountByUserId(options).then((request) => request(axios, basePath));
},
/**
* Get a single asset\'s information
* @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters.
@@ -6200,16 +6213,17 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAssetCountByUserId(options?: AxiosRequestConfig): AxiosPromise<AssetCountByUserIdResponseDto> {
return localVarFp.getAssetCountByUserId(options).then((request) => request(axios, basePath));
getAssetSearchTerms(options?: AxiosRequestConfig): AxiosPromise<Array<string>> {
return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath));
},
/**
*
* @param {AssetApiGetAssetStatsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAssetSearchTerms(options?: AxiosRequestConfig): AxiosPromise<Array<string>> {
return localVarFp.getAssetSearchTerms(options).then((request) => request(axios, basePath));
getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig): AxiosPromise<AssetStatsResponseDto> {
return localVarFp.getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(axios, basePath));
},
/**
*
@@ -6523,6 +6537,27 @@ export interface AssetApiGetAssetCountByTimeBucketRequest {
readonly getAssetCountByTimeBucketDto: GetAssetCountByTimeBucketDto
}
/**
* Request parameters for getAssetStats operation in AssetApi.
* @export
* @interface AssetApiGetAssetStatsRequest
*/
export interface AssetApiGetAssetStatsRequest {
/**
*
* @type {boolean}
* @memberof AssetApiGetAssetStats
*/
readonly isArchived?: boolean
/**
*
* @type {boolean}
* @memberof AssetApiGetAssetStats
*/
readonly isFavorite?: boolean
}
/**
* Request parameters for getAssetThumbnail operation in AssetApi.
* @export
@@ -6915,16 +6950,6 @@ export class AssetApi extends BaseAPI {
return AssetApiFp(this.configuration).getAllAssets(requestParameters.userId, requestParameters.isFavorite, requestParameters.isArchived, requestParameters.withoutThumbs, requestParameters.skip, requestParameters.ifNoneMatch, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getArchivedAssetCountByUserId(options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getArchivedAssetCountByUserId(options).then((request) => request(this.axios, this.basePath));
}
/**
* Get a single asset\'s information
* @param {AssetApiGetAssetByIdRequest} requestParameters Request parameters.
@@ -6964,18 +6989,19 @@ export class AssetApi extends BaseAPI {
* @throws {RequiredError}
* @memberof AssetApi
*/
public getAssetCountByUserId(options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAssetCountByUserId(options).then((request) => request(this.axios, this.basePath));
public getAssetSearchTerms(options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {AssetApiGetAssetStatsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof AssetApi
*/
public getAssetSearchTerms(options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAssetSearchTerms(options).then((request) => request(this.axios, this.basePath));
public getAssetStats(requestParameters: AssetApiGetAssetStatsRequest = {}, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getAssetStats(requestParameters.isArchived, requestParameters.isFavorite, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -8655,10 +8681,11 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getAllPeople: async (withHidden?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/person`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -8680,6 +8707,10 @@ export const PersonApiAxiosParamCreator = function (configuration?: Configuratio
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (withHidden !== undefined) {
localVarQueryParameter['withHidden'] = withHidden;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
@@ -8925,11 +8956,12 @@ export const PersonApiFp = function(configuration?: Configuration) {
return {
/**
*
* @param {boolean} [withHidden]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getAllPeople(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<PersonResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(options);
async getAllPeople(withHidden?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<PeopleResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getAllPeople(withHidden, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -8996,11 +9028,12 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
return {
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getAllPeople(options?: AxiosRequestConfig): AxiosPromise<Array<PersonResponseDto>> {
return localVarFp.getAllPeople(options).then((request) => request(axios, basePath));
getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig): AxiosPromise<PeopleResponseDto> {
return localVarFp.getAllPeople(requestParameters.withHidden, options).then((request) => request(axios, basePath));
},
/**
*
@@ -9050,6 +9083,20 @@ export const PersonApiFactory = function (configuration?: Configuration, basePat
};
};
/**
* Request parameters for getAllPeople operation in PersonApi.
* @export
* @interface PersonApiGetAllPeopleRequest
*/
export interface PersonApiGetAllPeopleRequest {
/**
*
* @type {boolean}
* @memberof PersonApiGetAllPeople
*/
readonly withHidden?: boolean
}
/**
* Request parameters for getPerson operation in PersonApi.
* @export
@@ -9143,12 +9190,13 @@ export interface PersonApiUpdatePersonRequest {
export class PersonApi extends BaseAPI {
/**
*
* @param {PersonApiGetAllPeopleRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof PersonApi
*/
public getAllPeople(options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(options).then((request) => request(this.axios, this.basePath));
public getAllPeople(requestParameters: PersonApiGetAllPeopleRequest = {}, options?: AxiosRequestConfig) {
return PersonApiFp(this.configuration).getAllPeople(requestParameters.withHidden, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -9747,6 +9795,35 @@ export const ServerInfoApiAxiosParamCreator = function (configuration?: Configur
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSupportedMediaTypes: async (options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/server-info/media-types`;
// 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;
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -9822,6 +9899,15 @@ export const ServerInfoApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getStats(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getSupportedMediaTypes(options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ServerMediaTypesResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getSupportedMediaTypes(options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {*} [options] Override http request option.
@@ -9865,6 +9951,14 @@ export const ServerInfoApiFactory = function (configuration?: Configuration, bas
getStats(options?: AxiosRequestConfig): AxiosPromise<ServerStatsResponseDto> {
return localVarFp.getStats(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getSupportedMediaTypes(options?: AxiosRequestConfig): AxiosPromise<ServerMediaTypesResponseDto> {
return localVarFp.getSupportedMediaTypes(options).then((request) => request(axios, basePath));
},
/**
*
* @param {*} [options] Override http request option.
@@ -9913,6 +10007,16 @@ export class ServerInfoApi extends BaseAPI {
return ServerInfoApiFp(this.configuration).getStats(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ServerInfoApi
*/
public getSupportedMediaTypes(options?: AxiosRequestConfig) {
return ServerInfoApiFp(this.configuration).getSupportedMediaTypes(options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {*} [options] Override http request option.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
version: "3.8"
services:
immich-server:
container_name: immich_server
image: immich-server:latest
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-server.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
depends_on:
- redis
- database
- typesense
immich-machine-learning:
container_name: immich_machine_learning
image: immich-machine-learning:latest
build:
context: ../machine-learning
dockerfile: Dockerfile
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
- model-cache:/cache
env_file:
- .env
restart: always
immich-microservices:
container_name: immich_microservices
image: immich-microservices:latest
build:
context: ../server
dockerfile: Dockerfile
command: ["./start-microservices.sh"]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
env_file:
- .env
depends_on:
- database
- immich-server
- typesense
restart: always
immich-web:
container_name: immich_web
image: immich-web:latest
build:
context: ../web
dockerfile: Dockerfile
env_file:
- .env
restart: always
depends_on:
- immich-server
typesense:
container_name: immich_typesense
image: typesense/typesense:0.24.1@sha256:9bcff2b829f12074426ca044b56160ca9d777a0c488303469143dd9f8259d4dd
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
volumes:
- tsdata:/data
restart: always
redis:
container_name: immich_redis
image: redis:6.2-alpine@sha256:70a7a5b641117670beae0d80658430853896b5ef269ccf00d1827427e3263fa3
restart: always
database:
container_name: immich_postgres
image: postgres:14-alpine@sha256:28407a9961e76f2d285dc6991e8e48893503cc3836a4755bbc2d40bcc272a441
env_file:
- .env
environment:
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_USER: ${DB_USERNAME}
POSTGRES_DB: ${DB_DATABASE_NAME}
PG_DATA: /var/lib/postgresql/data
volumes:
- pgdata:/var/lib/postgresql/data
restart: always
immich-proxy:
container_name: immich_proxy
image: immich-proxy:latest
environment:
# Make sure these values get passed through from the env file
- IMMICH_SERVER_URL
- IMMICH_WEB_URL
build:
context: ../nginx
dockerfile: Dockerfile
ports:
- 2283:8080
logging:
driver: none
depends_on:
- immich-server
restart: always
volumes:
pgdata:
model-cache:
tsdata:

View File

@@ -51,8 +51,6 @@ services:
environment:
- TYPESENSE_API_KEY=${TYPESENSE_API_KEY}
- TYPESENSE_DATA_DIR=/data
logging:
driver: none
volumes:
- tsdata:/data
restart: always

View File

@@ -94,7 +94,7 @@ To remove the **Metadata** you can stop Immich and delete the volume.
docker-compose down -v
```
After removing the the containers and volumes, the **Files** can be cleaned up (if necessary) from the `UPLOAD_LOCATION` by simply deleting an unwanted files or folders.
After removing the containers and volumes, the **Files** can be cleaned up (if necessary) from the `UPLOAD_LOCATION` by simply deleting an unwanted files or folders.
### Why iOS app shows duplicate photos on the timeline while the web doesn't?

View File

@@ -1,4 +1,4 @@
Immich allows the admin user to set the pattern of how the files are uploaded to the Immich would look like. Both in the directory and the filename level.
Immich allows the admin user to set the uploaded filename pattern. Both at the directory and filename level.
The admin user can set the template by using the template builder in the `Administration -> Settings -> Storage Template`. Immich provides a set of variables that you can use in constructing the template, along with additional custom text.

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.67.1"
version = "1.68.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 90,
"android.injected.version.name" => "1.67.1",
"android.injected.version.code" => 91,
"android.injected.version.name" => "1.68.0",
}
)
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')

View File

@@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta"
lane :beta do
increment_version_number(
version_number: "1.67.1"
version_number: "1.68.0"
)
increment_build_number(
build_number: latest_testflight_build_number + 1,

View File

@@ -93,7 +93,8 @@ Future<Isar> loadDb() async {
DuplicatedAssetSchema,
LoggerMessageSchema,
ETagSchema,
Platform.isAndroid ? AndroidDeviceAssetSchema : IOSDeviceAssetSchema,
if (Platform.isAndroid) AndroidDeviceAssetSchema,
if (Platform.isIOS) IOSDeviceAssetSchema,
],
directory: dir.path,
maxSizeMiB: 256,

View File

@@ -412,7 +412,11 @@ class GalleryViewerPage extends HookConsumerWidget {
showUnselectedLabels: false,
items: [
BottomNavigationBarItem(
icon: const Icon(Icons.ios_share_rounded),
icon: Icon(
Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
),
label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(),
),

View File

@@ -207,6 +207,9 @@ class BackupNotifier extends StateNotifier<BackUpState> {
type: RequestType.common,
);
// Map of id -> album for quick album lookup later on.
Map<String, AssetPathEntity> albumMap = {};
log.info('Found ${albums.length} local albums');
for (AssetPathEntity album in albums) {
@@ -235,6 +238,8 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
availableAlbums.add(availableAlbum);
albumMap[album.id] = album;
}
}
@@ -270,30 +275,37 @@ class BackupNotifier extends StateNotifier<BackUpState> {
}
// Generate AssetPathEntity from id to add to local state
try {
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = await AssetPathEntity.fromId(ba.id);
final Set<AvailableAlbum> selectedAlbums = {};
for (final BackupAlbum ba in selectedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
selectedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Selected album not found');
}
}
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = await AssetPathEntity.fromId(ba.id);
final Set<AvailableAlbum> excludedAlbums = {};
for (final BackupAlbum ba in excludedBackupAlbums) {
final albumAsset = albumMap[ba.id];
if (albumAsset != null) {
excludedAlbums.add(
AvailableAlbum(albumEntity: albumAsset, lastBackup: ba.lastBackup),
);
} else {
log.severe('Excluded album not found');
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
} catch (e, stackTrace) {
log.severe("Failed to generate album from id", e, stackTrace);
}
state = state.copyWith(
selectedBackupAlbums: selectedAlbums,
excludedBackupAlbums: excludedAlbums,
);
debugPrint("_getBackupAlbumsInfo takes ${stopwatch.elapsedMilliseconds}ms");
}

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -39,7 +41,9 @@ class ControlBottomAppBar extends ConsumerWidget {
return Row(
children: [
ControlBoxButton(
iconData: Icons.ios_share_rounded,
iconData: Platform.isAndroid
? Icons.share_rounded
: Icons.ios_share_rounded,
label: "control_bottom_app_bar_share".tr(),
onPressed: enabled ? onShare : null,
),

View File

@@ -33,14 +33,12 @@ class ProfileDrawer extends HookConsumerWidget {
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () async {
bool res = await ref.watch(authenticationProvider.notifier).logout();
await ref.watch(authenticationProvider.notifier).logout();
if (res) {
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
}
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
},
);
}

View File

@@ -14,6 +14,7 @@ import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
@@ -92,21 +93,29 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
}
}
Future<bool> logout() async {
Future<void> logout() async {
var log = Logger('AuthenticationNotifier');
try {
String? userEmail = Store.tryGet(StoreKey.currentUser)?.email;
_apiService.authenticationApi
.logout()
.then((_) => log.info("Logout was successfull for $userEmail"))
.onError(
(error, stackTrace) =>
log.severe("Error logging out $userEmail", error, stackTrace),
);
await Future.wait([
_apiService.authenticationApi.logout(),
clearAssetsAndAlbums(_db),
Store.delete(StoreKey.currentUser),
Store.delete(StoreKey.accessToken),
]);
state = state.copyWith(isAuthenticated: false);
return true;
} catch (e) {
debugPrint("Error logging out $e");
return false;
log.severe("Error logging out $e");
}
}

View File

@@ -75,18 +75,15 @@ class ChangePasswordForm extends HookConsumerWidget {
.changePassword(passwordController.value.text);
if (isSuccess) {
bool res = await ref
await ref
.read(authenticationProvider.notifier)
.logout();
if (res) {
ref.read(backupProvider.notifier).cancelBackup();
ref.read(assetProvider.notifier).clearAllAsset();
ref.read(websocketProvider.notifier).disconnect();
ref.read(backupProvider.notifier).cancelBackup();
ref.read(assetProvider.notifier).clearAllAsset();
ref.read(websocketProvider.notifier).disconnect();
AutoRouter.of(context)
.replace(const LoginRoute());
}
AutoRouter.of(context).replace(const LoginRoute());
}
}
},

View File

@@ -63,12 +63,3 @@ final getCuratedLocationProvider =
var curatedLocation = await searchService.getCuratedLocation();
return curatedLocation ?? [];
});
final getCuratedObjectProvider =
FutureProvider.autoDispose<List<CuratedObjectsResponseDto>>((ref) async {
final SearchService searchService = ref.watch(searchServiceProvider);
var curatedObject = await searchService.getCuratedObjects();
return curatedObject ?? [];
});

View File

@@ -18,7 +18,8 @@ class PersonService {
Future<List<PersonResponseDto>?> getCuratedPeople() async {
try {
return await _apiService.personApi.getAllPeople();
final peopleResponseDto = await _apiService.personApi.getAllPeople();
return peopleResponseDto?.people;
} catch (e) {
debugPrint("Error [getCuratedPeople] ${e.toString()}");
return null;

View File

@@ -1,55 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/search/models/curated_content.dart';
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
import 'package:immich_mobile/modules/search/ui/explore_grid.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/utils/capitalize.dart';
import 'package:openapi/api.dart';
class CuratedObjectPage extends HookConsumerWidget {
const CuratedObjectPage({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<CuratedObjectsResponseDto>> curatedObjects =
ref.watch(getCuratedObjectProvider);
return Scaffold(
appBar: AppBar(
title: Text(
'curated_object_page_title',
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 16.0,
),
).tr(),
leading: IconButton(
onPressed: () => AutoRouter.of(context).pop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: curatedObjects.when(
loading: () => const Center(child: ImmichLoadingIndicator()),
error: (err, stack) => Center(
child: Text('Error: $err'),
),
data: (curatedLocations) => ExploreGrid(
curatedContent: curatedLocations
.map(
(l) => CuratedContent(
label: l.object.capitalize(),
id: l.id,
),
)
.toList(),
),
),
);
}
}

View File

@@ -25,7 +25,6 @@ class SearchPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final isSearchEnabled = ref.watch(searchPageStateProvider).isSearchEnabled;
final curatedLocation = ref.watch(getCuratedLocationProvider);
final curatedObjects = ref.watch(getCuratedObjectProvider);
final curatedPeople = ref.watch(getCuratedPeopleProvider);
var isDarkTheme = Theme.of(context).brightness == Brightness.dark;
double imageSize = MediaQuery.of(context).size.width / 3;
@@ -128,40 +127,6 @@ class SearchPage extends HookConsumerWidget {
);
}
buildThings() {
return SizedBox(
height: imageSize,
child: curatedObjects.when(
loading: () => SizedBox(
height: imageSize,
child: const Center(child: ImmichLoadingIndicator()),
),
error: (err, stack) => SizedBox(
height: imageSize,
child: Center(child: Text('Error: $err')),
),
data: (objects) => CuratedRow(
content: objects
.map(
(o) => CuratedContent(
id: o.id,
label: o.object,
),
)
.toList(),
imageSize: imageSize,
onTap: (content, index) {
AutoRouter.of(context).push(
SearchResultRoute(
searchTerm: 'm:${content.label}',
),
);
},
),
),
);
}
return Scaffold(
appBar: ImmichSearchBar(
searchFocusNode: searchFocusNode,
@@ -191,13 +156,6 @@ class SearchPage extends HookConsumerWidget {
top: 0,
),
buildPlaces(),
SearchRowTitle(
title: "search_page_things".tr(),
onViewAllPressed: () => AutoRouter.of(context).push(
const CuratedObjectRoute(),
),
),
buildThings(),
const SizedBox(height: 24.0),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),

View File

@@ -30,7 +30,6 @@ import 'package:immich_mobile/modules/search/views/all_motion_videos_page.dart';
import 'package:immich_mobile/modules/search/views/all_people_page.dart';
import 'package:immich_mobile/modules/search/views/all_videos_page.dart';
import 'package:immich_mobile/modules/search/views/curated_location_page.dart';
import 'package:immich_mobile/modules/search/views/curated_object_page.dart';
import 'package:immich_mobile/modules/search/views/person_result_page.dart';
import 'package:immich_mobile/modules/search/views/recently_added_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart';
@@ -87,7 +86,6 @@ part 'router.gr.dart';
AutoRoute(page: BackupControllerPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: SearchResultPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CuratedLocationPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CuratedObjectPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: CreateAlbumPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: FavoritesPage, guards: [AuthGuard, DuplicateGuard]),
AutoRoute(page: AllVideosPage, guards: [AuthGuard, DuplicateGuard]),

View File

@@ -111,12 +111,6 @@ class _$AppRouter extends RootStackRouter {
child: const CuratedLocationPage(),
);
},
CuratedObjectRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData,
child: const CuratedObjectPage(),
);
},
CreateAlbumRoute.name: (routeData) {
final args = routeData.argsAs<CreateAlbumRouteArgs>();
return MaterialPageX<dynamic>(
@@ -441,14 +435,6 @@ class _$AppRouter extends RootStackRouter {
duplicateGuard,
],
),
RouteConfig(
CuratedObjectRoute.name,
path: '/curated-object-page',
guards: [
authGuard,
duplicateGuard,
],
),
RouteConfig(
CreateAlbumRoute.name,
path: '/create-album-page',
@@ -507,7 +493,7 @@ class _$AppRouter extends RootStackRouter {
),
RouteConfig(
AlbumViewerRoute.name,
path: '/',
path: '/album-viewer-page',
guards: [
authGuard,
duplicateGuard,
@@ -839,18 +825,6 @@ class CuratedLocationRoute extends PageRouteInfo<void> {
static const String name = 'CuratedLocationRoute';
}
/// generated route for
/// [CuratedObjectPage]
class CuratedObjectRoute extends PageRouteInfo<void> {
const CuratedObjectRoute()
: super(
CuratedObjectRoute.name,
path: '/curated-object-page',
);
static const String name = 'CuratedObjectRoute';
}
/// generated route for
/// [CreateAlbumPage]
class CreateAlbumRoute extends PageRouteInfo<CreateAlbumRouteArgs> {
@@ -1020,7 +994,7 @@ class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
required int albumId,
}) : super(
AlbumViewerRoute.name,
path: '/',
path: '/album-viewer-page',
args: AlbumViewerRouteArgs(
key: key,
albumId: albumId,

View File

@@ -33,7 +33,6 @@ class TabNavigationObserver extends AutoRouterObserver {
if (route.name == 'SearchRoute') {
// Refresh Location State
ref.invalidate(getCuratedLocationProvider);
ref.invalidate(getCuratedObjectProvider);
ref.invalidate(getCuratedPeopleProvider);
}

View File

@@ -23,11 +23,11 @@ doc/AssetBulkUploadCheckResponseDto.md
doc/AssetBulkUploadCheckResult.md
doc/AssetCountByTimeBucket.md
doc/AssetCountByTimeBucketResponseDto.md
doc/AssetCountByUserIdResponseDto.md
doc/AssetFileUploadResponseDto.md
doc/AssetIdsDto.md
doc/AssetIdsResponseDto.md
doc/AssetResponseDto.md
doc/AssetStatsResponseDto.md
doc/AssetTypeEnum.md
doc/AudioCodec.md
doc/AuthDeviceResponseDto.md
@@ -71,6 +71,7 @@ doc/OAuthCallbackDto.md
doc/OAuthConfigDto.md
doc/OAuthConfigResponseDto.md
doc/PartnerApi.md
doc/PeopleResponseDto.md
doc/PersonApi.md
doc/PersonResponseDto.md
doc/PersonUpdateDto.md
@@ -88,6 +89,7 @@ doc/SearchFacetResponseDto.md
doc/SearchResponseDto.md
doc/ServerInfoApi.md
doc/ServerInfoResponseDto.md
doc/ServerMediaTypesResponseDto.md
doc/ServerPingResponse.md
doc/ServerStatsResponseDto.md
doc/ServerVersionReponseDto.md
@@ -163,11 +165,11 @@ lib/model/asset_bulk_upload_check_response_dto.dart
lib/model/asset_bulk_upload_check_result.dart
lib/model/asset_count_by_time_bucket.dart
lib/model/asset_count_by_time_bucket_response_dto.dart
lib/model/asset_count_by_user_id_response_dto.dart
lib/model/asset_file_upload_response_dto.dart
lib/model/asset_ids_dto.dart
lib/model/asset_ids_response_dto.dart
lib/model/asset_response_dto.dart
lib/model/asset_stats_response_dto.dart
lib/model/asset_type_enum.dart
lib/model/audio_codec.dart
lib/model/auth_device_response_dto.dart
@@ -207,6 +209,7 @@ lib/model/merge_person_dto.dart
lib/model/o_auth_callback_dto.dart
lib/model/o_auth_config_dto.dart
lib/model/o_auth_config_response_dto.dart
lib/model/people_response_dto.dart
lib/model/person_response_dto.dart
lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart
@@ -221,6 +224,7 @@ lib/model/search_facet_count_response_dto.dart
lib/model/search_facet_response_dto.dart
lib/model/search_response_dto.dart
lib/model/server_info_response_dto.dart
lib/model/server_media_types_response_dto.dart
lib/model/server_ping_response.dart
lib/model/server_stats_response_dto.dart
lib/model/server_version_reponse_dto.dart
@@ -272,11 +276,11 @@ test/asset_bulk_upload_check_response_dto_test.dart
test/asset_bulk_upload_check_result_test.dart
test/asset_count_by_time_bucket_response_dto_test.dart
test/asset_count_by_time_bucket_test.dart
test/asset_count_by_user_id_response_dto_test.dart
test/asset_file_upload_response_dto_test.dart
test/asset_ids_dto_test.dart
test/asset_ids_response_dto_test.dart
test/asset_response_dto_test.dart
test/asset_stats_response_dto_test.dart
test/asset_type_enum_test.dart
test/audio_codec_test.dart
test/auth_device_response_dto_test.dart
@@ -320,6 +324,7 @@ test/o_auth_callback_dto_test.dart
test/o_auth_config_dto_test.dart
test/o_auth_config_response_dto_test.dart
test/partner_api_test.dart
test/people_response_dto_test.dart
test/person_api_test.dart
test/person_response_dto_test.dart
test/person_update_dto_test.dart
@@ -337,6 +342,7 @@ test/search_facet_response_dto_test.dart
test/search_response_dto_test.dart
test/server_info_api_test.dart
test/server_info_response_dto_test.dart
test/server_media_types_response_dto_test.dart
test/server_ping_response_test.dart
test/server_stats_response_dto_test.dart
test/server_version_reponse_dto_test.dart

View File

@@ -3,7 +3,7 @@ Immich API
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
- API version: 1.67.1
- API version: 1.68.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -94,12 +94,11 @@ Class | Method | HTTP request | Description
*AssetApi* | [**downloadArchive**](doc//AssetApi.md#downloadarchive) | **POST** /asset/download |
*AssetApi* | [**downloadFile**](doc//AssetApi.md#downloadfile) | **POST** /asset/download/{id} |
*AssetApi* | [**getAllAssets**](doc//AssetApi.md#getallassets) | **GET** /asset |
*AssetApi* | [**getArchivedAssetCountByUserId**](doc//AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive |
*AssetApi* | [**getAssetById**](doc//AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} |
*AssetApi* | [**getAssetByTimeBucket**](doc//AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
*AssetApi* | [**getAssetCountByTimeBucket**](doc//AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
*AssetApi* | [**getAssetCountByUserId**](doc//AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
*AssetApi* | [**getAssetSearchTerms**](doc//AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
*AssetApi* | [**getAssetStats**](doc//AssetApi.md#getassetstats) | **GET** /asset/statistics |
*AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} |
*AssetApi* | [**getCuratedLocations**](doc//AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
*AssetApi* | [**getCuratedObjects**](doc//AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects |
@@ -142,6 +141,7 @@ Class | Method | HTTP request | Description
*ServerInfoApi* | [**getServerInfo**](doc//ServerInfoApi.md#getserverinfo) | **GET** /server-info |
*ServerInfoApi* | [**getServerVersion**](doc//ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
*ServerInfoApi* | [**getStats**](doc//ServerInfoApi.md#getstats) | **GET** /server-info/stats |
*ServerInfoApi* | [**getSupportedMediaTypes**](doc//ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types |
*ServerInfoApi* | [**pingServer**](doc//ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
*SharedLinkApi* | [**addSharedLinkAssets**](doc//SharedLinkApi.md#addsharedlinkassets) | **PUT** /shared-link/{id}/assets |
*SharedLinkApi* | [**createSharedLink**](doc//SharedLinkApi.md#createsharedlink) | **POST** /shared-link |
@@ -194,11 +194,11 @@ Class | Method | HTTP request | Description
- [AssetBulkUploadCheckResult](doc//AssetBulkUploadCheckResult.md)
- [AssetCountByTimeBucket](doc//AssetCountByTimeBucket.md)
- [AssetCountByTimeBucketResponseDto](doc//AssetCountByTimeBucketResponseDto.md)
- [AssetCountByUserIdResponseDto](doc//AssetCountByUserIdResponseDto.md)
- [AssetFileUploadResponseDto](doc//AssetFileUploadResponseDto.md)
- [AssetIdsDto](doc//AssetIdsDto.md)
- [AssetIdsResponseDto](doc//AssetIdsResponseDto.md)
- [AssetResponseDto](doc//AssetResponseDto.md)
- [AssetStatsResponseDto](doc//AssetStatsResponseDto.md)
- [AssetTypeEnum](doc//AssetTypeEnum.md)
- [AudioCodec](doc//AudioCodec.md)
- [AuthDeviceResponseDto](doc//AuthDeviceResponseDto.md)
@@ -238,6 +238,7 @@ Class | Method | HTTP request | Description
- [OAuthCallbackDto](doc//OAuthCallbackDto.md)
- [OAuthConfigDto](doc//OAuthConfigDto.md)
- [OAuthConfigResponseDto](doc//OAuthConfigResponseDto.md)
- [PeopleResponseDto](doc//PeopleResponseDto.md)
- [PersonResponseDto](doc//PersonResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
@@ -252,6 +253,7 @@ Class | Method | HTTP request | Description
- [SearchFacetResponseDto](doc//SearchFacetResponseDto.md)
- [SearchResponseDto](doc//SearchResponseDto.md)
- [ServerInfoResponseDto](doc//ServerInfoResponseDto.md)
- [ServerMediaTypesResponseDto](doc//ServerMediaTypesResponseDto.md)
- [ServerPingResponse](doc//ServerPingResponse.md)
- [ServerStatsResponseDto](doc//ServerStatsResponseDto.md)
- [ServerVersionReponseDto](doc//ServerVersionReponseDto.md)

View File

@@ -16,12 +16,11 @@ Method | HTTP request | Description
[**downloadArchive**](AssetApi.md#downloadarchive) | **POST** /asset/download |
[**downloadFile**](AssetApi.md#downloadfile) | **POST** /asset/download/{id} |
[**getAllAssets**](AssetApi.md#getallassets) | **GET** /asset |
[**getArchivedAssetCountByUserId**](AssetApi.md#getarchivedassetcountbyuserid) | **GET** /asset/stat/archive |
[**getAssetById**](AssetApi.md#getassetbyid) | **GET** /asset/assetById/{id} |
[**getAssetByTimeBucket**](AssetApi.md#getassetbytimebucket) | **POST** /asset/time-bucket |
[**getAssetCountByTimeBucket**](AssetApi.md#getassetcountbytimebucket) | **POST** /asset/count-by-time-bucket |
[**getAssetCountByUserId**](AssetApi.md#getassetcountbyuserid) | **GET** /asset/count-by-user-id |
[**getAssetSearchTerms**](AssetApi.md#getassetsearchterms) | **GET** /asset/search-terms |
[**getAssetStats**](AssetApi.md#getassetstats) | **GET** /asset/statistics |
[**getAssetThumbnail**](AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} |
[**getCuratedLocations**](AssetApi.md#getcuratedlocations) | **GET** /asset/curated-locations |
[**getCuratedObjects**](AssetApi.md#getcuratedobjects) | **GET** /asset/curated-objects |
@@ -445,57 +444,6 @@ Name | Type | Description | Notes
[[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)
# **getArchivedAssetCountByUserId**
> AssetCountByUserIdResponseDto getArchivedAssetCountByUserId()
### 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 = AssetApi();
try {
final result = api_instance.getArchivedAssetCountByUserId();
print(result);
} catch (e) {
print('Exception when calling AssetApi->getArchivedAssetCountByUserId: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.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)
# **getAssetById**
> AssetResponseDto getAssetById(id, key)
@@ -665,57 +613,6 @@ Name | Type | Description | Notes
[[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)
# **getAssetCountByUserId**
> AssetCountByUserIdResponseDto getAssetCountByUserId()
### 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 = AssetApi();
try {
final result = api_instance.getAssetCountByUserId();
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAssetCountByUserId: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**AssetCountByUserIdResponseDto**](AssetCountByUserIdResponseDto.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)
# **getAssetSearchTerms**
> List<String> getAssetSearchTerms()
@@ -767,6 +664,63 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getAssetStats**
> AssetStatsResponseDto getAssetStats(isArchived, isFavorite)
### 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 = AssetApi();
final isArchived = true; // bool |
final isFavorite = true; // bool |
try {
final result = api_instance.getAssetStats(isArchived, isFavorite);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getAssetStats: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**isArchived** | **bool**| | [optional]
**isFavorite** | **bool**| | [optional]
### Return type
[**AssetStatsResponseDto**](AssetStatsResponseDto.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)
# **getAssetThumbnail**
> MultipartFile getAssetThumbnail(id, format, key)

View File

@@ -1,4 +1,4 @@
# openapi.model.AssetCountByUserIdResponseDto
# openapi.model.AssetStatsResponseDto
## Load the model package
```dart
@@ -8,11 +8,9 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**audio** | **int** | | [default to 0]
**photos** | **int** | | [default to 0]
**videos** | **int** | | [default to 0]
**other** | **int** | | [default to 0]
**total** | **int** | | [default to 0]
**images** | **int** | |
**videos** | **int** | |
**total** | **int** | |
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

17
mobile/openapi/doc/PeopleResponseDto.md generated Normal file
View File

@@ -0,0 +1,17 @@
# openapi.model.PeopleResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**total** | **num** | |
**visible** | **num** | |
**people** | [**List<PersonResponseDto>**](PersonResponseDto.md) | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -18,7 +18,7 @@ Method | HTTP request | Description
# **getAllPeople**
> List<PersonResponseDto> getAllPeople()
> PeopleResponseDto getAllPeople(withHidden)
@@ -41,9 +41,10 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = PersonApi();
final withHidden = true; // bool |
try {
final result = api_instance.getAllPeople();
final result = api_instance.getAllPeople(withHidden);
print(result);
} catch (e) {
print('Exception when calling PersonApi->getAllPeople: $e\n');
@@ -51,11 +52,14 @@ try {
```
### Parameters
This endpoint does not need any parameter.
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**withHidden** | **bool**| | [optional] [default to false]
### Return type
[**List<PersonResponseDto>**](PersonResponseDto.md)
[**PeopleResponseDto**](PeopleResponseDto.md)
### Authorization

View File

@@ -11,6 +11,7 @@ Name | Type | Description | Notes
**id** | **String** | |
**name** | **String** | |
**thumbnailPath** | **String** | |
**isHidden** | **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)

View File

@@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**name** | **String** | Person name. | [optional]
**featureFaceAssetId** | **String** | Asset is used to get the feature face thumbnail. | [optional]
**isHidden** | **bool** | Person visibility | [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)

View File

@@ -12,6 +12,7 @@ Method | HTTP request | Description
[**getServerInfo**](ServerInfoApi.md#getserverinfo) | **GET** /server-info |
[**getServerVersion**](ServerInfoApi.md#getserverversion) | **GET** /server-info/version |
[**getStats**](ServerInfoApi.md#getstats) | **GET** /server-info/stats |
[**getSupportedMediaTypes**](ServerInfoApi.md#getsupportedmediatypes) | **GET** /server-info/media-types |
[**pingServer**](ServerInfoApi.md#pingserver) | **GET** /server-info/ping |
@@ -154,6 +155,43 @@ This endpoint does not need any parameter.
[[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
# **getSupportedMediaTypes**
> ServerMediaTypesResponseDto getSupportedMediaTypes()
### Example
```dart
import 'package:openapi/api.dart';
final api_instance = ServerInfoApi();
try {
final result = api_instance.getSupportedMediaTypes();
print(result);
} catch (e) {
print('Exception when calling ServerInfoApi->getSupportedMediaTypes: $e\n');
}
```
### Parameters
This endpoint does not need any parameter.
### Return type
[**ServerMediaTypesResponseDto**](ServerMediaTypesResponseDto.md)
### Authorization
No authorization required
### 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)
# **pingServer**
> ServerPingResponse pingServer()

View File

@@ -0,0 +1,17 @@
# openapi.model.ServerMediaTypesResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**video** | **List<String>** | | [default to const []]
**image** | **List<String>** | | [default to const []]
**sidecar** | **List<String>** | | [default to const []]
[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md)

View File

@@ -13,6 +13,7 @@ Name | Type | Description | Notes
**clientId** | **String** | |
**clientSecret** | **String** | |
**scope** | **String** | |
**storageLabelClaim** | **String** | |
**buttonText** | **String** | |
**autoRegister** | **bool** | |
**autoLaunch** | **bool** | |

View File

@@ -60,11 +60,11 @@ part 'model/asset_bulk_upload_check_response_dto.dart';
part 'model/asset_bulk_upload_check_result.dart';
part 'model/asset_count_by_time_bucket.dart';
part 'model/asset_count_by_time_bucket_response_dto.dart';
part 'model/asset_count_by_user_id_response_dto.dart';
part 'model/asset_file_upload_response_dto.dart';
part 'model/asset_ids_dto.dart';
part 'model/asset_ids_response_dto.dart';
part 'model/asset_response_dto.dart';
part 'model/asset_stats_response_dto.dart';
part 'model/asset_type_enum.dart';
part 'model/audio_codec.dart';
part 'model/auth_device_response_dto.dart';
@@ -104,6 +104,7 @@ part 'model/merge_person_dto.dart';
part 'model/o_auth_callback_dto.dart';
part 'model/o_auth_config_dto.dart';
part 'model/o_auth_config_response_dto.dart';
part 'model/people_response_dto.dart';
part 'model/person_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/queue_status_dto.dart';
@@ -118,6 +119,7 @@ part 'model/search_facet_count_response_dto.dart';
part 'model/search_facet_response_dto.dart';
part 'model/search_response_dto.dart';
part 'model/server_info_response_dto.dart';
part 'model/server_media_types_response_dto.dart';
part 'model/server_ping_response.dart';
part 'model/server_stats_response_dto.dart';
part 'model/server_version_reponse_dto.dart';

View File

@@ -440,47 +440,6 @@ class AssetApi {
return null;
}
/// Performs an HTTP 'GET /asset/stat/archive' operation and returns the [Response].
Future<Response> getArchivedAssetCountByUserIdWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/asset/stat/archive';
// 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<AssetCountByUserIdResponseDto?> getArchivedAssetCountByUserId() async {
final response = await getArchivedAssetCountByUserIdWithHttpInfo();
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), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto;
}
return null;
}
/// Get a single asset's information
///
/// Note: This method returns the HTTP [Response].
@@ -639,47 +598,6 @@ class AssetApi {
return null;
}
/// Performs an HTTP 'GET /asset/count-by-user-id' operation and returns the [Response].
Future<Response> getAssetCountByUserIdWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/asset/count-by-user-id';
// 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<AssetCountByUserIdResponseDto?> getAssetCountByUserId() async {
final response = await getAssetCountByUserIdWithHttpInfo();
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), 'AssetCountByUserIdResponseDto',) as AssetCountByUserIdResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /asset/search-terms' operation and returns the [Response].
Future<Response> getAssetSearchTermsWithHttpInfo() async {
// ignore: prefer_const_declarations
@@ -724,6 +642,64 @@ class AssetApi {
return null;
}
/// Performs an HTTP 'GET /asset/statistics' operation and returns the [Response].
/// Parameters:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
Future<Response> getAssetStatsWithHttpInfo({ bool? isArchived, bool? isFavorite, }) async {
// ignore: prefer_const_declarations
final path = r'/asset/statistics';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (isArchived != null) {
queryParams.addAll(_queryParams('', 'isArchived', isArchived));
}
if (isFavorite != null) {
queryParams.addAll(_queryParams('', 'isFavorite', isFavorite));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [bool] isArchived:
///
/// * [bool] isFavorite:
Future<AssetStatsResponseDto?> getAssetStats({ bool? isArchived, bool? isFavorite, }) async {
final response = await getAssetStatsWithHttpInfo( isArchived: isArchived, isFavorite: isFavorite, );
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), 'AssetStatsResponseDto',) as AssetStatsResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /asset/thumbnail/{id}' operation and returns the [Response].
/// Parameters:
///

View File

@@ -17,7 +17,10 @@ class PersonApi {
final ApiClient apiClient;
/// Performs an HTTP 'GET /person' operation and returns the [Response].
Future<Response> getAllPeopleWithHttpInfo() async {
/// Parameters:
///
/// * [bool] withHidden:
Future<Response> getAllPeopleWithHttpInfo({ bool? withHidden, }) async {
// ignore: prefer_const_declarations
final path = r'/person';
@@ -28,6 +31,10 @@ class PersonApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (withHidden != null) {
queryParams.addAll(_queryParams('', 'withHidden', withHidden));
}
const contentTypes = <String>[];
@@ -42,8 +49,11 @@ class PersonApi {
);
}
Future<List<PersonResponseDto>?> getAllPeople() async {
final response = await getAllPeopleWithHttpInfo();
/// Parameters:
///
/// * [bool] withHidden:
Future<PeopleResponseDto?> getAllPeople({ bool? withHidden, }) async {
final response = await getAllPeopleWithHttpInfo( withHidden: withHidden, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
@@ -51,11 +61,8 @@ class PersonApi {
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<PersonResponseDto>') as List)
.cast<PersonResponseDto>()
.toList();
return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'PeopleResponseDto',) as PeopleResponseDto;
}
return null;
}

View File

@@ -139,6 +139,47 @@ class ServerInfoApi {
return null;
}
/// Performs an HTTP 'GET /server-info/media-types' operation and returns the [Response].
Future<Response> getSupportedMediaTypesWithHttpInfo() async {
// ignore: prefer_const_declarations
final path = r'/server-info/media-types';
// 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<ServerMediaTypesResponseDto?> getSupportedMediaTypes() async {
final response = await getSupportedMediaTypesWithHttpInfo();
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), 'ServerMediaTypesResponseDto',) as ServerMediaTypesResponseDto;
}
return null;
}
/// Performs an HTTP 'GET /server-info/ping' operation and returns the [Response].
Future<Response> pingServerWithHttpInfo() async {
// ignore: prefer_const_declarations

View File

@@ -215,8 +215,6 @@ class ApiClient {
return AssetCountByTimeBucket.fromJson(value);
case 'AssetCountByTimeBucketResponseDto':
return AssetCountByTimeBucketResponseDto.fromJson(value);
case 'AssetCountByUserIdResponseDto':
return AssetCountByUserIdResponseDto.fromJson(value);
case 'AssetFileUploadResponseDto':
return AssetFileUploadResponseDto.fromJson(value);
case 'AssetIdsDto':
@@ -225,6 +223,8 @@ class ApiClient {
return AssetIdsResponseDto.fromJson(value);
case 'AssetResponseDto':
return AssetResponseDto.fromJson(value);
case 'AssetStatsResponseDto':
return AssetStatsResponseDto.fromJson(value);
case 'AssetTypeEnum':
return AssetTypeEnumTypeTransformer().decode(value);
case 'AudioCodec':
@@ -303,6 +303,8 @@ class ApiClient {
return OAuthConfigDto.fromJson(value);
case 'OAuthConfigResponseDto':
return OAuthConfigResponseDto.fromJson(value);
case 'PeopleResponseDto':
return PeopleResponseDto.fromJson(value);
case 'PersonResponseDto':
return PersonResponseDto.fromJson(value);
case 'PersonUpdateDto':
@@ -331,6 +333,8 @@ class ApiClient {
return SearchResponseDto.fromJson(value);
case 'ServerInfoResponseDto':
return ServerInfoResponseDto.fromJson(value);
case 'ServerMediaTypesResponseDto':
return ServerMediaTypesResponseDto.fromJson(value);
case 'ServerPingResponse':
return ServerPingResponse.fromJson(value);
case 'ServerStatsResponseDto':

View File

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

View File

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

View File

@@ -0,0 +1,114 @@
//
// 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 PeopleResponseDto {
/// Returns a new [PeopleResponseDto] instance.
PeopleResponseDto({
required this.total,
required this.visible,
this.people = const [],
});
num total;
num visible;
List<PersonResponseDto> people;
@override
bool operator ==(Object other) => identical(this, other) || other is PeopleResponseDto &&
other.total == total &&
other.visible == visible &&
other.people == people;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(total.hashCode) +
(visible.hashCode) +
(people.hashCode);
@override
String toString() => 'PeopleResponseDto[total=$total, visible=$visible, people=$people]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'total'] = this.total;
json[r'visible'] = this.visible;
json[r'people'] = this.people;
return json;
}
/// Returns a new [PeopleResponseDto] instance and imports its values from
/// [value] if it's a [Map], null otherwise.
// ignore: prefer_constructors_over_static_methods
static PeopleResponseDto? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return PeopleResponseDto(
total: num.parse('${json[r'total']}'),
visible: num.parse('${json[r'visible']}'),
people: PersonResponseDto.listFromJson(json[r'people']),
);
}
return null;
}
static List<PeopleResponseDto> listFromJson(dynamic json, {bool growable = false,}) {
final result = <PeopleResponseDto>[];
if (json is List && json.isNotEmpty) {
for (final row in json) {
final value = PeopleResponseDto.fromJson(row);
if (value != null) {
result.add(value);
}
}
}
return result.toList(growable: growable);
}
static Map<String, PeopleResponseDto> mapFromJson(dynamic json) {
final map = <String, PeopleResponseDto>{};
if (json is Map && json.isNotEmpty) {
json = json.cast<String, dynamic>(); // ignore: parameter_assignments
for (final entry in json.entries) {
final value = PeopleResponseDto.fromJson(entry.value);
if (value != null) {
map[entry.key] = value;
}
}
}
return map;
}
// maps a json object with a list of PeopleResponseDto-objects as value to a dart map
static Map<String, List<PeopleResponseDto>> mapListFromJson(dynamic json, {bool growable = false,}) {
final map = <String, List<PeopleResponseDto>>{};
if (json is Map && json.isNotEmpty) {
// ignore: parameter_assignments
json = json.cast<String, dynamic>();
for (final entry in json.entries) {
map[entry.key] = PeopleResponseDto.listFromJson(entry.value, growable: growable,);
}
}
return map;
}
/// The list of required keys that must be present in a JSON.
static const requiredKeys = <String>{
'total',
'visible',
'people',
};
}

View File

@@ -16,6 +16,7 @@ class PersonResponseDto {
required this.id,
required this.name,
required this.thumbnailPath,
required this.isHidden,
});
String id;
@@ -24,27 +25,32 @@ class PersonResponseDto {
String thumbnailPath;
bool isHidden;
@override
bool operator ==(Object other) => identical(this, other) || other is PersonResponseDto &&
other.id == id &&
other.name == name &&
other.thumbnailPath == thumbnailPath;
other.thumbnailPath == thumbnailPath &&
other.isHidden == isHidden;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(id.hashCode) +
(name.hashCode) +
(thumbnailPath.hashCode);
(thumbnailPath.hashCode) +
(isHidden.hashCode);
@override
String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath]';
String toString() => 'PersonResponseDto[id=$id, name=$name, thumbnailPath=$thumbnailPath, isHidden=$isHidden]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json[r'id'] = this.id;
json[r'name'] = this.name;
json[r'thumbnailPath'] = this.thumbnailPath;
json[r'isHidden'] = this.isHidden;
return json;
}
@@ -59,6 +65,7 @@ class PersonResponseDto {
id: mapValueOfType<String>(json, r'id')!,
name: mapValueOfType<String>(json, r'name')!,
thumbnailPath: mapValueOfType<String>(json, r'thumbnailPath')!,
isHidden: mapValueOfType<bool>(json, r'isHidden')!,
);
}
return null;
@@ -109,6 +116,7 @@ class PersonResponseDto {
'id',
'name',
'thumbnailPath',
'isHidden',
};
}

View File

@@ -15,6 +15,7 @@ class PersonUpdateDto {
PersonUpdateDto({
this.name,
this.featureFaceAssetId,
this.isHidden,
});
/// Person name.
@@ -35,19 +36,30 @@ class PersonUpdateDto {
///
String? featureFaceAssetId;
/// Person visibility
///
/// 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.
///
bool? isHidden;
@override
bool operator ==(Object other) => identical(this, other) || other is PersonUpdateDto &&
other.name == name &&
other.featureFaceAssetId == featureFaceAssetId;
other.featureFaceAssetId == featureFaceAssetId &&
other.isHidden == isHidden;
@override
int get hashCode =>
// ignore: unnecessary_parenthesis
(name == null ? 0 : name!.hashCode) +
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode);
(featureFaceAssetId == null ? 0 : featureFaceAssetId!.hashCode) +
(isHidden == null ? 0 : isHidden!.hashCode);
@override
String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId]';
String toString() => 'PersonUpdateDto[name=$name, featureFaceAssetId=$featureFaceAssetId, isHidden=$isHidden]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -61,6 +73,11 @@ class PersonUpdateDto {
} else {
// json[r'featureFaceAssetId'] = null;
}
if (this.isHidden != null) {
json[r'isHidden'] = this.isHidden;
} else {
// json[r'isHidden'] = null;
}
return json;
}
@@ -74,6 +91,7 @@ class PersonUpdateDto {
return PersonUpdateDto(
name: mapValueOfType<String>(json, r'name'),
featureFaceAssetId: mapValueOfType<String>(json, r'featureFaceAssetId'),
isHidden: mapValueOfType<bool>(json, r'isHidden'),
);
}
return null;

View File

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

View File

@@ -18,6 +18,7 @@ class SystemConfigOAuthDto {
required this.clientId,
required this.clientSecret,
required this.scope,
required this.storageLabelClaim,
required this.buttonText,
required this.autoRegister,
required this.autoLaunch,
@@ -35,6 +36,8 @@ class SystemConfigOAuthDto {
String scope;
String storageLabelClaim;
String buttonText;
bool autoRegister;
@@ -52,6 +55,7 @@ class SystemConfigOAuthDto {
other.clientId == clientId &&
other.clientSecret == clientSecret &&
other.scope == scope &&
other.storageLabelClaim == storageLabelClaim &&
other.buttonText == buttonText &&
other.autoRegister == autoRegister &&
other.autoLaunch == autoLaunch &&
@@ -66,6 +70,7 @@ class SystemConfigOAuthDto {
(clientId.hashCode) +
(clientSecret.hashCode) +
(scope.hashCode) +
(storageLabelClaim.hashCode) +
(buttonText.hashCode) +
(autoRegister.hashCode) +
(autoLaunch.hashCode) +
@@ -73,7 +78,7 @@ class SystemConfigOAuthDto {
(mobileRedirectUri.hashCode);
@override
String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, buttonText=$buttonText, autoRegister=$autoRegister, autoLaunch=$autoLaunch, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri]';
String toString() => 'SystemConfigOAuthDto[enabled=$enabled, issuerUrl=$issuerUrl, clientId=$clientId, clientSecret=$clientSecret, scope=$scope, storageLabelClaim=$storageLabelClaim, buttonText=$buttonText, autoRegister=$autoRegister, autoLaunch=$autoLaunch, mobileOverrideEnabled=$mobileOverrideEnabled, mobileRedirectUri=$mobileRedirectUri]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@@ -82,6 +87,7 @@ class SystemConfigOAuthDto {
json[r'clientId'] = this.clientId;
json[r'clientSecret'] = this.clientSecret;
json[r'scope'] = this.scope;
json[r'storageLabelClaim'] = this.storageLabelClaim;
json[r'buttonText'] = this.buttonText;
json[r'autoRegister'] = this.autoRegister;
json[r'autoLaunch'] = this.autoLaunch;
@@ -103,6 +109,7 @@ class SystemConfigOAuthDto {
clientId: mapValueOfType<String>(json, r'clientId')!,
clientSecret: mapValueOfType<String>(json, r'clientSecret')!,
scope: mapValueOfType<String>(json, r'scope')!,
storageLabelClaim: mapValueOfType<String>(json, r'storageLabelClaim')!,
buttonText: mapValueOfType<String>(json, r'buttonText')!,
autoRegister: mapValueOfType<bool>(json, r'autoRegister')!,
autoLaunch: mapValueOfType<bool>(json, r'autoLaunch')!,
@@ -160,6 +167,7 @@ class SystemConfigOAuthDto {
'clientId',
'clientSecret',
'scope',
'storageLabelClaim',
'buttonText',
'autoRegister',
'autoLaunch',

View File

@@ -60,11 +60,6 @@ void main() {
// TODO
});
//Future<AssetCountByUserIdResponseDto> getArchivedAssetCountByUserId() async
test('test getArchivedAssetCountByUserId', () async {
// TODO
});
// Get a single asset's information
//
//Future<AssetResponseDto> getAssetById(String id, { String key }) async
@@ -82,13 +77,13 @@ void main() {
// TODO
});
//Future<AssetCountByUserIdResponseDto> getAssetCountByUserId() async
test('test getAssetCountByUserId', () async {
//Future<List<String>> getAssetSearchTerms() async
test('test getAssetSearchTerms', () async {
// TODO
});
//Future<List<String>> getAssetSearchTerms() async
test('test getAssetSearchTerms', () async {
//Future<AssetStatsResponseDto> getAssetStats({ bool isArchived, bool isFavorite }) async
test('test getAssetStats', () async {
// TODO
});

View File

@@ -11,32 +11,22 @@
import 'package:openapi/api.dart';
import 'package:test/test.dart';
// tests for AssetCountByUserIdResponseDto
// tests for AssetStatsResponseDto
void main() {
// final instance = AssetCountByUserIdResponseDto();
// final instance = AssetStatsResponseDto();
group('test AssetCountByUserIdResponseDto', () {
// int audio (default value: 0)
test('to test the property `audio`', () async {
group('test AssetStatsResponseDto', () {
// int images
test('to test the property `images`', () async {
// TODO
});
// int photos (default value: 0)
test('to test the property `photos`', () async {
// TODO
});
// int videos (default value: 0)
// int videos
test('to test the property `videos`', () async {
// TODO
});
// int other (default value: 0)
test('to test the property `other`', () async {
// TODO
});
// int total (default value: 0)
// int total
test('to test the property `total`', () async {
// TODO
});

View File

@@ -0,0 +1,37 @@
//
// 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 PeopleResponseDto
void main() {
// final instance = PeopleResponseDto();
group('test PeopleResponseDto', () {
// num total
test('to test the property `total`', () async {
// TODO
});
// num visible
test('to test the property `visible`', () async {
// TODO
});
// List<PersonResponseDto> people (default value: const [])
test('to test the property `people`', () async {
// TODO
});
});
}

View File

@@ -17,7 +17,7 @@ void main() {
// final instance = PersonApi();
group('tests for PersonApi', () {
//Future<List<PersonResponseDto>> getAllPeople() async
//Future<PeopleResponseDto> getAllPeople({ bool withHidden }) async
test('test getAllPeople', () async {
// TODO
});

View File

@@ -31,6 +31,11 @@ void main() {
// TODO
});
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
});

View File

@@ -28,6 +28,12 @@ void main() {
// TODO
});
// Person visibility
// bool isHidden
test('to test the property `isHidden`', () async {
// TODO
});
});

View File

@@ -32,6 +32,11 @@ void main() {
// TODO
});
//Future<ServerMediaTypesResponseDto> getSupportedMediaTypes() async
test('test getSupportedMediaTypes', () async {
// TODO
});
//Future<ServerPingResponse> pingServer() async
test('test pingServer', () async {
// TODO

View File

@@ -0,0 +1,37 @@
//
// 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 ServerMediaTypesResponseDto
void main() {
// final instance = ServerMediaTypesResponseDto();
group('test ServerMediaTypesResponseDto', () {
// List<String> video (default value: const [])
test('to test the property `video`', () async {
// TODO
});
// List<String> image (default value: const [])
test('to test the property `image`', () async {
// TODO
});
// List<String> sidecar (default value: const [])
test('to test the property `sidecar`', () async {
// TODO
});
});
}

View File

@@ -41,6 +41,11 @@ void main() {
// TODO
});
// String storageLabelClaim
test('to test the property `storageLabelClaim`', () async {
// TODO
});
// String buttonText
test('to test the property `buttonText`', () async {
// TODO

View File

@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.67.1+90
version: 1.68.0+91
isar_version: &isar_version 3.1.0+1
environment:

View File

@@ -984,38 +984,6 @@
]
}
},
"/asset/count-by-user-id": {
"get": {
"operationId": "getAssetCountByUserId",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetCountByUserIdResponseDto"
}
}
}
}
},
"tags": [
"Asset"
],
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
]
}
},
"/asset/curated-locations": {
"get": {
"operationId": "getCuratedLocations",
@@ -1608,17 +1576,34 @@
]
}
},
"/asset/stat/archive": {
"/asset/statistics": {
"get": {
"operationId": "getArchivedAssetCountByUserId",
"parameters": [],
"operationId": "getAssetStats",
"parameters": [
{
"name": "isArchived",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isFavorite",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AssetCountByUserIdResponseDto"
"$ref": "#/components/schemas/AssetStatsResponseDto"
}
}
}
@@ -2524,17 +2509,24 @@
"/person": {
"get": {
"operationId": "getAllPeople",
"parameters": [],
"parameters": [
{
"name": "withHidden",
"required": false,
"in": "query",
"schema": {
"default": false,
"type": "boolean"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
"$ref": "#/components/schemas/PeopleResponseDto"
}
}
}
@@ -3055,6 +3047,27 @@
]
}
},
"/server-info/media-types": {
"get": {
"operationId": "getSupportedMediaTypes",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ServerMediaTypesResponseDto"
}
}
}
}
},
"tags": [
"Server Info"
]
}
},
"/server-info/ping": {
"get": {
"operationId": "pingServer",
@@ -4383,7 +4396,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "1.67.1",
"version": "1.68.0",
"contact": {}
},
"tags": [],
@@ -4786,38 +4799,6 @@
"buckets"
]
},
"AssetCountByUserIdResponseDto": {
"type": "object",
"properties": {
"audio": {
"type": "integer",
"default": 0
},
"photos": {
"type": "integer",
"default": 0
},
"videos": {
"type": "integer",
"default": 0
},
"other": {
"type": "integer",
"default": 0
},
"total": {
"type": "integer",
"default": 0
}
},
"required": [
"audio",
"photos",
"videos",
"other",
"total"
]
},
"AssetFileUploadResponseDto": {
"type": "object",
"properties": {
@@ -4970,6 +4951,25 @@
"checksum"
]
},
"AssetStatsResponseDto": {
"type": "object",
"properties": {
"images": {
"type": "integer"
},
"videos": {
"type": "integer"
},
"total": {
"type": "integer"
}
},
"required": [
"images",
"videos",
"total"
]
},
"AssetTypeEnum": {
"type": "string",
"enum": [
@@ -5884,6 +5884,28 @@
"passwordLoginEnabled"
]
},
"PeopleResponseDto": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"visible": {
"type": "number"
},
"people": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PersonResponseDto"
}
}
},
"required": [
"total",
"visible",
"people"
]
},
"PersonResponseDto": {
"type": "object",
"properties": {
@@ -5895,12 +5917,16 @@
},
"thumbnailPath": {
"type": "string"
},
"isHidden": {
"type": "boolean"
}
},
"required": [
"id",
"name",
"thumbnailPath"
"thumbnailPath",
"isHidden"
]
},
"PersonUpdateDto": {
@@ -5913,6 +5939,10 @@
"featureFaceAssetId": {
"type": "string",
"description": "Asset is used to get the feature face thumbnail."
},
"isHidden": {
"type": "boolean",
"description": "Person visibility"
}
}
},
@@ -6146,6 +6176,34 @@
"diskAvailable"
]
},
"ServerMediaTypesResponseDto": {
"type": "object",
"properties": {
"video": {
"type": "array",
"items": {
"type": "string"
}
},
"image": {
"type": "array",
"items": {
"type": "string"
}
},
"sidecar": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": [
"video",
"image",
"sidecar"
]
},
"ServerPingResponse": {
"type": "object",
"properties": {
@@ -6531,6 +6589,9 @@
"scope": {
"type": "string"
},
"storageLabelClaim": {
"type": "string"
},
"buttonText": {
"type": "string"
},
@@ -6553,6 +6614,7 @@
"clientId",
"clientSecret",
"scope",
"storageLabelClaim",
"buttonText",
"autoRegister",
"autoLaunch",

View File

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

View File

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

View File

@@ -1,6 +1,13 @@
import { AssetEntity, AssetType } from '@app/infra/entities';
import { Paginated, PaginationOptions } from '../domain.util';
export type AssetStats = Record<AssetType, number>;
export interface AssetStatsOptions {
isFavorite?: boolean;
isArchived?: boolean;
}
export interface AssetSearchOptions {
isVisible?: boolean;
type?: AssetType;
@@ -55,4 +62,5 @@ export interface IAssetRepository {
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;
}

View File

@@ -1,18 +1,21 @@
import { BadRequestException } from '@nestjs/common';
import { AssetType } from '@app/infra/entities';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import {
assetEntityStub,
authStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newAssetRepositoryMock,
newCryptoRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { Readable } from 'stream';
import { ICryptoRepository } from '../crypto';
import { IStorageRepository } from '../storage';
import { IAssetRepository } from './asset.repository';
import { AssetService } from './asset.service';
import { DownloadResponseDto } from './index';
import { AssetStats, IAssetRepository } from './asset.repository';
import { AssetService, UploadFieldName } from './asset.service';
import { AssetStatsResponseDto, DownloadResponseDto } from './dto';
import { mapAsset } from './response-dto';
const downloadResponse: DownloadResponseDto = {
@@ -25,10 +28,123 @@ const downloadResponse: DownloadResponseDto = {
],
};
const stats: AssetStats = {
[AssetType.IMAGE]: 10,
[AssetType.VIDEO]: 23,
[AssetType.AUDIO]: 0,
[AssetType.OTHER]: 0,
};
const statResponse: AssetStatsResponseDto = {
images: 10,
videos: 23,
total: 33,
};
const uploadFile = {
nullAuth: {
authUser: null,
fieldName: UploadFieldName.ASSET_DATA,
file: {
checksum: Buffer.from('checksum', 'utf8'),
originalPath: 'upload/admin/image.jpeg',
originalName: 'image.jpeg',
},
},
filename: (fieldName: UploadFieldName, filename: string) => {
return {
authUser: authStub.admin,
fieldName,
file: {
mimeType: 'image/jpeg',
checksum: Buffer.from('checksum', 'utf8'),
originalPath: `upload/admin/${filename}`,
originalName: filename,
},
};
},
};
const validImages = [
'.3fr',
'.ari',
'.arw',
'.avif',
'.cap',
'.cin',
'.cr2',
'.cr3',
'.crw',
'.dcr',
'.dng',
'.erf',
'.fff',
'.gif',
'.heic',
'.heif',
'.iiq',
'.jpeg',
'.jpg',
'.jxl',
'.k25',
'.kdc',
'.mrw',
'.nef',
'.orf',
'.ori',
'.pef',
'.png',
'.raf',
'.raw',
'.rwl',
'.sr2',
'.srf',
'.srw',
'.tiff',
'.webp',
'.x3f',
];
const validVideos = ['.3gp', '.avi', '.flv', '.m2ts', '.mkv', '.mov', '.mp4', '.mpg', '.mts', '.webm', '.wmv'];
const uploadTests = [
{
label: 'asset images',
fieldName: UploadFieldName.ASSET_DATA,
valid: validImages,
invalid: ['.html', '.xml'],
},
{
label: 'asset videos',
fieldName: UploadFieldName.ASSET_DATA,
valid: validVideos,
invalid: ['.html', '.xml'],
},
{
label: 'live photo',
fieldName: UploadFieldName.LIVE_PHOTO_DATA,
valid: validVideos,
invalid: ['.html', '.jpeg', '.jpg', '.xml'],
},
{
label: 'sidecar',
fieldName: UploadFieldName.SIDECAR_DATA,
valid: ['.xmp'],
invalid: ['.html', '.jpeg', '.jpg', '.mov', '.mp4', '.xml'],
},
{
label: 'profile',
fieldName: UploadFieldName.PROFILE_DATA,
valid: ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'],
invalid: ['.arf', '.cr2', '.html', '.mov', '.mp4', '.xml'],
},
];
describe(AssetService.name, () => {
let sut: AssetService;
let accessMock: IAccessRepositoryMock;
let assetMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
it('should work', () => {
@@ -38,8 +154,93 @@ describe(AssetService.name, () => {
beforeEach(async () => {
accessMock = newAccessRepositoryMock();
assetMock = newAssetRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetMock, storageMock);
sut = new AssetService(accessMock, assetMock, cryptoMock, storageMock);
});
describe('canUpload', () => {
it('should require an authenticated user', () => {
expect(() => sut.canUploadFile(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
for (const { fieldName, valid, invalid } of uploadTests) {
describe(fieldName, () => {
for (const filetype of valid) {
it(`should accept ${filetype}`, () => {
expect(sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toEqual(true);
});
}
for (const filetype of invalid) {
it(`should reject ${filetype}`, () => {
expect(() => sut.canUploadFile(uploadFile.filename(fieldName, `asset${filetype}`))).toThrowError(
BadRequestException,
);
});
}
it('should be sorted (valid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(valid).toEqual([...valid].sort());
});
it('should be sorted (invalid)', () => {
// TODO: use toSorted in NodeJS 20.
expect(invalid).toEqual([...invalid].sort());
});
});
}
});
describe('getUploadFilename', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFilename(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should be the original extension for asset upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
it('should be the mov extension for live photo upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.LIVE_PHOTO_DATA, 'image.mp4'))).toEqual(
'random-uuid.mov',
);
});
it('should be the xmp extension for sidecar upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.SIDECAR_DATA, 'image.html'))).toEqual(
'random-uuid.xmp',
);
});
it('should be the original extension for profile upload', () => {
expect(sut.getUploadFilename(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'random-uuid.jpg',
);
});
});
describe('getUploadFolder', () => {
it('should require authentication', () => {
expect(() => sut.getUploadFolder(uploadFile.nullAuth)).toThrowError(UnauthorizedException);
});
it('should return profile for profile uploads', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.PROFILE_DATA, 'image.jpg'))).toEqual(
'upload/profile/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile/admin_id');
});
it('should return upload for everything else', () => {
expect(sut.getUploadFolder(uploadFile.filename(UploadFieldName.ASSET_DATA, 'image.jpg'))).toEqual(
'upload/upload/admin_id',
);
expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload/admin_id');
});
});
describe('getMapMarkers', () => {
@@ -287,4 +488,30 @@ describe(AssetService.name, () => {
});
});
});
describe('getStatistics', () => {
it('should get the statistics for a user, excluding archived assets', async () => {
assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isArchived: false })).resolves.toEqual(statResponse);
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: false });
});
it('should get the statistics for a user for archived assets', async () => {
assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isArchived: true })).resolves.toEqual(statResponse);
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isArchived: true });
});
it('should get the statistics for a user for favorite assets', async () => {
assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse);
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, { isFavorite: true });
});
it('should get the statistics for a user for all assets', async () => {
assetMock.getStatistics.mockResolvedValue(stats);
await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse);
expect(assetMock.getStatistics).toHaveBeenCalledWith(authStub.admin.id, {});
});
});
});

View File

@@ -1,14 +1,17 @@
import { AssetEntity } from '@app/infra/entities';
import { BadRequestException, Inject } from '@nestjs/common';
import { BadRequestException, Inject, Logger } from '@nestjs/common';
import { DateTime } from 'luxon';
import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AccessCore, IAccessRepository, Permission } from '../access';
import { AuthUserDto } from '../auth';
import { ICryptoRepository } from '../crypto';
import { mimeTypes } from '../domain.constant';
import { HumanReadableSize, usePagination } from '../domain.util';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { ImmichReadStream, IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IAssetRepository } from './asset.repository';
import { AssetIdsDto, DownloadArchiveInfo, DownloadDto, DownloadResponseDto, MemoryLaneDto } from './dto';
import { AssetStatsDto, mapStats } from './dto/asset-statistics.dto';
import { MapMarkerDto } from './dto/map-marker.dto';
import { mapAsset, MapMarkerResponseDto } from './response-dto';
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
@@ -20,6 +23,12 @@ export enum UploadFieldName {
PROFILE_DATA = 'file',
}
export interface UploadRequest {
authUser: AuthUserDto | null;
fieldName: UploadFieldName;
file: UploadFile;
}
export interface UploadFile {
checksum: Buffer;
originalPath: string;
@@ -27,16 +36,82 @@ export interface UploadFile {
}
export class AssetService {
private logger = new Logger(AssetService.name);
private access: AccessCore;
private storageCore = new StorageCore();
constructor(
@Inject(IAccessRepository) accessRepository: IAccessRepository,
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
) {
this.access = new AccessCore(accessRepository);
}
canUploadFile({ authUser, fieldName, file }: UploadRequest): true {
this.access.requireUploadAccess(authUser);
const filename = file.originalName;
switch (fieldName) {
case UploadFieldName.ASSET_DATA:
if (mimeTypes.isAsset(filename)) {
return true;
}
break;
case UploadFieldName.LIVE_PHOTO_DATA:
if (mimeTypes.isVideo(filename)) {
return true;
}
break;
case UploadFieldName.SIDECAR_DATA:
if (mimeTypes.isSidecar(filename)) {
return true;
}
break;
case UploadFieldName.PROFILE_DATA:
if (mimeTypes.isProfile(filename)) {
return true;
}
break;
}
this.logger.error(`Unsupported file type ${filename}`);
throw new BadRequestException(`Unsupported file type ${filename}`);
}
getUploadFilename({ authUser, fieldName, file }: UploadRequest): string {
this.access.requireUploadAccess(authUser);
const originalExt = extname(file.originalName);
const lookup = {
[UploadFieldName.ASSET_DATA]: originalExt,
[UploadFieldName.LIVE_PHOTO_DATA]: '.mov',
[UploadFieldName.SIDECAR_DATA]: '.xmp',
[UploadFieldName.PROFILE_DATA]: originalExt,
};
return sanitize(`${this.cryptoRepository.randomUUID()}${lookup[fieldName]}`);
}
getUploadFolder({ authUser, fieldName }: UploadRequest): string {
authUser = this.access.requireUploadAccess(authUser);
let folder = this.storageCore.getFolderLocation(StorageFolder.UPLOAD, authUser.id);
if (fieldName === UploadFieldName.PROFILE_DATA) {
folder = this.storageCore.getFolderLocation(StorageFolder.PROFILE, authUser.id);
}
this.storageRepository.mkdirSync(folder);
return folder;
}
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
return this.assetRepository.getMapMarkers(authUser.id, options);
}
@@ -155,4 +230,9 @@ export class AssetService {
throw new BadRequestException('assetIds, albumId, or userId is required');
}
async getStatistics(authUser: AuthUserDto, dto: AssetStatsDto) {
const stats = await this.assetRepository.getStatistics(authUser.id, dto);
return mapStats(stats);
}
}

View File

@@ -0,0 +1,37 @@
import { AssetType } from '@app/infra/entities';
import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional } from 'class-validator';
import { toBoolean } from '../../domain.util';
import { AssetStats } from '../asset.repository';
export class AssetStatsDto {
@IsBoolean()
@Transform(toBoolean)
@IsOptional()
isArchived?: boolean;
@IsBoolean()
@Transform(toBoolean)
@IsOptional()
isFavorite?: boolean;
}
export class AssetStatsResponseDto {
@ApiProperty({ type: 'integer' })
images!: number;
@ApiProperty({ type: 'integer' })
videos!: number;
@ApiProperty({ type: 'integer' })
total!: number;
}
export const mapStats = (stats: AssetStats): AssetStatsResponseDto => {
return {
images: stats[AssetType.IMAGE],
videos: stats[AssetType.VIDEO],
total: Object.values(stats).reduce((total, value) => total + value, 0),
};
};

View File

@@ -1,4 +1,5 @@
export * from './asset-ids.dto';
export * from './asset-statistics.dto';
export * from './download.dto';
export * from './map-marker.dto';
export * from './memory-lane.dto';

View File

@@ -54,7 +54,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
smartInfo: entity.smartInfo ? mapSmartInfo(entity.smartInfo) : undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map(mapTag),
people: entity.faces?.map(mapFace),
people: entity.faces?.map(mapFace).filter((person) => !person.isHidden),
checksum: entity.checksum.toString('base64'),
};
}

View File

@@ -1,3 +1,5 @@
export const MOBILE_REDIRECT = 'app.immich:/';
export const LOGIN_URL = '/auth/login?autoLaunch=0';
export const IMMICH_ACCESS_COOKIE = 'immich_access_token';
export const IMMICH_AUTH_TYPE_COOKIE = 'immich_auth_type';
export const IMMICH_API_KEY_NAME = 'api_key';

View File

@@ -1,62 +0,0 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_AUTH_TYPE_COOKIE } from './auth.constant';
import { LoginResponseDto, mapLoginResponse } from './response-dto';
export interface LoginDetails {
isSecure: boolean;
clientIp: string;
deviceType: string;
deviceOS: string;
}
export class AuthCore {
private userTokenCore: UserTokenCore;
constructor(
private cryptoRepository: ICryptoRepository,
configRepository: ISystemConfigRepository,
userTokenRepository: IUserTokenRepository,
private config: SystemConfig,
) {
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
const configCore = new SystemConfigCore(configRepository);
configCore.config$.subscribe((config) => (this.config = config));
}
isPasswordLoginEnabled() {
return this.config.passwordLogin.enabled;
}
getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
const maxAge = 400 * 24 * 3600; // 400 days
let authTypeCookie = '';
let accessTokenCookie = '';
if (isSecure) {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
} else {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
}
return [accessTokenCookie, authTypeCookie];
}
async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
const accessToken = await this.userTokenCore.create(user, loginDetails);
const response = mapLoginResponse(user, accessToken);
const cookie = this.getCookies(response, authType, loginDetails);
return { response, cookie };
}
validatePassword(inputPassword: string, user: UserEntity): boolean {
if (!user || !user.password) {
return false;
}
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
}
}

View File

@@ -1,4 +1,4 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { UserEntity } from '@app/infra/entities';
import { BadRequestException, UnauthorizedException } from '@nestjs/common';
import {
authStub,
@@ -23,10 +23,10 @@ import { ICryptoRepository } from '../crypto/crypto.repository';
import { ISharedLinkRepository } from '../shared-link';
import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
import { IUserTokenRepository } from '../user-token';
import { AuthType } from './auth.constant';
import { AuthService } from './auth.service';
import { AuthUserDto, SignUpDto } from './dto';
import { IUserTokenRepository } from './user-token.repository';
// const token = Buffer.from('my-api-key', 'utf8').toString('base64');
@@ -55,7 +55,6 @@ describe('AuthService', () => {
let shareMock: jest.Mocked<ISharedLinkRepository>;
let keyMock: jest.Mocked<IKeyRepository>;
let callbackMock: jest.Mock;
let create: (config: SystemConfig) => AuthService;
afterEach(() => {
jest.resetModules();
@@ -87,9 +86,7 @@ describe('AuthService', () => {
shareMock = newSharedLinkRepositoryMock();
keyMock = newKeyRepositoryMock();
create = (config) => new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock, config);
sut = create(systemConfigStub.enabled);
sut = new AuthService(cryptoMock, configMock, userMock, userTokenMock, shareMock, keyMock);
});
it('should be defined', () => {
@@ -98,8 +95,7 @@ describe('AuthService', () => {
describe('login', () => {
it('should throw an error if password login is disabled', async () => {
sut = create(systemConfigStub.disabled);
configMock.load.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
});
@@ -191,8 +187,8 @@ describe('AuthService', () => {
describe('logout', () => {
it('should return the end session endpoint', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
const authUser = { id: '123' } as AuthUserDto;
await expect(sut.logout(authUser, AuthType.OAUTH)).resolves.toEqual({
successful: true,
redirectUri: 'http://end-session-endpoint',
@@ -385,4 +381,132 @@ describe('AuthService', () => {
expect(userTokenMock.delete).toHaveBeenCalledWith(authStub.user1.id, 'token-1');
});
});
describe('getMobileRedirect', () => {
it('should pass along the query params', () => {
expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
});
it('should work if called without query params', () => {
expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
});
});
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
configMock.load.mockResolvedValue(systemConfigStub.disabled);
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
enabled: false,
passwordLoginEnabled: false,
});
});
it('should generate the config', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
url: 'http://authorization-url',
autoLaunch: false,
passwordLoginEnabled: true,
});
});
});
describe('callback', () => {
it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.callback({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(null);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
configMock.load.mockResolvedValue(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
userMock.update.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
});
it('should allow auto registering by default', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
userMock.create.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(userMock.create).toHaveBeenCalledTimes(1);
});
it('should use the mobile redirect override', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.callback({ url: `app.immich:/?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
configMock.load.mockResolvedValue(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.callback({ url: `app.immich:///?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
});
describe('link', () => {
it('should link an account', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.update).not.toHaveBeenCalled();
});
});
describe('unlink', () => {
it('should unlink an account', async () => {
configMock.load.mockResolvedValue(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.unlink(authStub.user1);
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
});
});
});

View File

@@ -1,4 +1,4 @@
import { SystemConfig } from '@app/infra/entities';
import { SystemConfig, UserEntity } from '@app/infra/entities';
import {
BadRequestException,
Inject,
@@ -9,99 +9,112 @@ import {
} from '@nestjs/common';
import cookieParser from 'cookie';
import { IncomingHttpHeaders } from 'http';
import { DateTime } from 'luxon';
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import { IKeyRepository } from '../api-key';
import { ICryptoRepository } from '../crypto/crypto.repository';
import { OAuthCore } from '../oauth/oauth.core';
import { ISharedLinkRepository } from '../shared-link';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore } from '../user';
import { IUserTokenRepository, UserTokenCore } from '../user-token';
import { AuthType, IMMICH_ACCESS_COOKIE, IMMICH_API_KEY_HEADER } from './auth.constant';
import { AuthCore, LoginDetails } from './auth.core';
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, SignUpDto } from './dto';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { IUserRepository, UserCore, UserResponseDto } from '../user';
import {
AuthType,
IMMICH_ACCESS_COOKIE,
IMMICH_API_KEY_HEADER,
IMMICH_AUTH_TYPE_COOKIE,
LOGIN_URL,
MOBILE_REDIRECT,
} from './auth.constant';
import { AuthUserDto, ChangePasswordDto, LoginCredentialDto, OAuthCallbackDto, OAuthConfigDto, SignUpDto } from './dto';
import {
AdminSignupResponseDto,
AuthDeviceResponseDto,
LoginResponseDto,
LogoutResponseDto,
mapAdminSignupResponse,
mapLoginResponse,
mapUserToken,
OAuthConfigResponseDto,
} from './response-dto';
import { IUserTokenRepository } from './user-token.repository';
export interface LoginDetails {
isSecure: boolean;
clientIp: string;
deviceType: string;
deviceOS: string;
}
interface LoginResponse {
response: LoginResponseDto;
cookie: string[];
}
interface OAuthProfile extends UserinfoResponse {
email: string;
}
@Injectable()
export class AuthService {
private userTokenCore: UserTokenCore;
private authCore: AuthCore;
private oauthCore: OAuthCore;
private userCore: UserCore;
private configCore: SystemConfigCore;
private logger = new Logger(AuthService.name);
constructor(
@Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) userRepository: IUserRepository,
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
@Inject(IUserTokenRepository) private userTokenRepository: IUserTokenRepository,
@Inject(ISharedLinkRepository) private sharedLinkRepository: ISharedLinkRepository,
@Inject(IKeyRepository) private keyRepository: IKeyRepository,
@Inject(INITIAL_SYSTEM_CONFIG)
initialConfig: SystemConfig,
) {
this.userTokenCore = new UserTokenCore(cryptoRepository, userTokenRepository);
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
this.oauthCore = new OAuthCore(configRepository, initialConfig);
this.configCore = new SystemConfigCore(configRepository);
this.userCore = new UserCore(userRepository, cryptoRepository);
custom.setHttpOptionsDefaults({ timeout: 30000 });
}
public async login(
loginCredential: LoginCredentialDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
if (!this.authCore.isPasswordLoginEnabled()) {
async login(dto: LoginCredentialDto, details: LoginDetails): Promise<LoginResponse> {
const config = await this.configCore.getConfig();
if (!config.passwordLogin.enabled) {
throw new UnauthorizedException('Password login has been disabled');
}
let user = await this.userCore.getByEmail(loginCredential.email, true);
let user = await this.userCore.getByEmail(dto.email, true);
if (user) {
const isAuthenticated = this.authCore.validatePassword(loginCredential.password, user);
const isAuthenticated = this.validatePassword(dto.password, user);
if (!isAuthenticated) {
user = null;
}
}
if (!user) {
this.logger.warn(
`Failed login attempt for user ${loginCredential.email} from ip address ${loginDetails.clientIp}`,
);
this.logger.warn(`Failed login attempt for user ${dto.email} from ip address ${details.clientIp}`);
throw new BadRequestException('Incorrect email or password');
}
return this.authCore.createLoginResponse(user, AuthType.PASSWORD, loginDetails);
return this.createLoginResponse(user, AuthType.PASSWORD, details);
}
public async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
async logout(authUser: AuthUserDto, authType: AuthType): Promise<LogoutResponseDto> {
if (authUser.accessTokenId) {
await this.userTokenCore.delete(authUser.id, authUser.accessTokenId);
await this.userTokenRepository.delete(authUser.id, authUser.accessTokenId);
}
if (authType === AuthType.OAUTH) {
const url = await this.oauthCore.getLogoutEndpoint();
if (url) {
return { successful: true, redirectUri: url };
}
}
return { successful: true, redirectUri: '/auth/login?autoLaunch=0' };
return {
successful: true,
redirectUri: await this.getLogoutEndpoint(authType),
};
}
public async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
async changePassword(authUser: AuthUserDto, dto: ChangePasswordDto) {
const { password, newPassword } = dto;
const user = await this.userCore.getByEmail(authUser.email, true);
if (!user) {
throw new UnauthorizedException();
}
const valid = this.authCore.validatePassword(password, user);
const valid = this.validatePassword(password, user);
if (!valid) {
throw new BadRequestException('Wrong password');
}
@@ -109,7 +122,7 @@ export class AuthService {
return this.userCore.updateUser(authUser, authUser.id, { password: newPassword });
}
public async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
async adminSignUp(dto: SignUpDto): Promise<AdminSignupResponseDto> {
const adminUser = await this.userCore.getAdmin();
if (adminUser) {
@@ -133,7 +146,7 @@ export class AuthService {
}
}
public async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
async validate(headers: IncomingHttpHeaders, params: Record<string, string>): Promise<AuthUserDto | null> {
const shareKey = (headers['x-immich-share-key'] || params.key) as string;
const userToken = (headers['x-immich-user-token'] ||
params.userToken ||
@@ -146,7 +159,7 @@ export class AuthService {
}
if (userToken) {
return this.userTokenCore.validate(userToken);
return this.validateUserToken(userToken);
}
if (apiKey) {
@@ -157,24 +170,163 @@ export class AuthService {
}
async getDevices(authUser: AuthUserDto): Promise<AuthDeviceResponseDto[]> {
const userTokens = await this.userTokenCore.getAll(authUser.id);
const userTokens = await this.userTokenRepository.getAll(authUser.id);
return userTokens.map((userToken) => mapUserToken(userToken, authUser.accessTokenId));
}
async logoutDevice(authUser: AuthUserDto, deviceId: string): Promise<void> {
await this.userTokenCore.delete(authUser.id, deviceId);
await this.userTokenRepository.delete(authUser.id, deviceId);
}
async logoutDevices(authUser: AuthUserDto): Promise<void> {
const devices = await this.userTokenCore.getAll(authUser.id);
const devices = await this.userTokenRepository.getAll(authUser.id);
for (const device of devices) {
if (device.id === authUser.accessTokenId) {
continue;
}
await this.userTokenCore.delete(authUser.id, device.id);
await this.userTokenRepository.delete(authUser.id, device.id);
}
}
getMobileRedirect(url: string) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
const config = await this.configCore.getConfig();
const response = {
enabled: config.oauth.enabled,
passwordLoginEnabled: config.passwordLogin.enabled,
};
if (!response.enabled) {
return response;
}
const { scope, buttonText, autoLaunch } = config.oauth;
const url = (await this.getOAuthClient(config)).authorizationUrl({
redirect_uri: this.normalize(config, dto.redirectUri),
scope,
state: generators.state(),
});
return { ...response, buttonText, url, autoLaunch };
}
async callback(
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
const config = await this.configCore.getConfig();
const profile = await this.getOAuthProfile(config, dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub);
// link existing user
if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email);
if (emailUser) {
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
}
}
// register new user
if (!user) {
if (!config.oauth.autoRegister) {
this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
this.logger.verbose(`OAuth Profile: ${JSON.stringify(profile)}`);
let storageLabel: string | null = profile[config.oauth.storageLabelClaim as keyof OAuthProfile] as string;
if (typeof storageLabel !== 'string') {
storageLabel = null;
}
user = await this.userCore.createUser({
firstName: profile.given_name || '',
lastName: profile.family_name || '',
email: profile.email,
oauthId: profile.sub,
storageLabel,
});
}
return this.createLoginResponse(user, AuthType.OAUTH, loginDetails);
}
async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const config = await this.configCore.getConfig();
const { sub: oauthId } = await this.getOAuthProfile(config, dto.url);
const duplicate = await this.userCore.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return this.userCore.updateUser(user, user.id, { oauthId });
}
async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return this.userCore.updateUser(user, user.id, { oauthId: '' });
}
private async getLogoutEndpoint(authType: AuthType): Promise<string> {
if (authType !== AuthType.OAUTH) {
return LOGIN_URL;
}
const config = await this.configCore.getConfig();
if (!config.oauth.enabled) {
return LOGIN_URL;
}
const client = await this.getOAuthClient(config);
return client.issuer.metadata.end_session_endpoint || LOGIN_URL;
}
private async getOAuthProfile(config: SystemConfig, url: string): Promise<OAuthProfile> {
const redirectUri = this.normalize(config, url.split('?')[0]);
const client = await this.getOAuthClient(config);
const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
return client.userinfo<OAuthProfile>(tokens.access_token || '');
}
private async getOAuthClient(config: SystemConfig) {
const { enabled, clientId, clientSecret, issuerUrl } = config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata);
}
private normalize(config: SystemConfig, redirectUri: string) {
const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
const { mobileRedirectUri, mobileOverrideEnabled } = config.oauth;
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
return mobileRedirectUri;
}
return redirectUri;
}
private getBearerToken(headers: IncomingHttpHeaders): string | null {
const [type, token] = (headers.authorization || '').split(' ');
if (type.toLowerCase() === 'bearer') {
@@ -232,4 +384,68 @@ export class AuthService {
throw new UnauthorizedException('Invalid API key');
}
private validatePassword(inputPassword: string, user: UserEntity): boolean {
if (!user || !user.password) {
return false;
}
return this.cryptoRepository.compareBcrypt(inputPassword, user.password);
}
private async validateUserToken(tokenValue: string): Promise<AuthUserDto> {
const hashedToken = this.cryptoRepository.hashSha256(tokenValue);
let token = await this.userTokenRepository.getByToken(hashedToken);
if (token?.user) {
const now = DateTime.now();
const updatedAt = DateTime.fromJSDate(token.updatedAt);
const diff = now.diff(updatedAt, ['hours']);
if (diff.hours > 1) {
token = await this.userTokenRepository.save({ ...token, updatedAt: new Date() });
}
return {
...token.user,
isPublicUser: false,
isAllowUpload: true,
isAllowDownload: true,
isShowExif: true,
accessTokenId: token.id,
};
}
throw new UnauthorizedException('Invalid user token');
}
private async createLoginResponse(user: UserEntity, authType: AuthType, loginDetails: LoginDetails) {
const key = this.cryptoRepository.randomBytes(32).toString('base64').replace(/\W/g, '');
const token = this.cryptoRepository.hashSha256(key);
await this.userTokenRepository.create({
token,
user,
deviceOS: loginDetails.deviceOS,
deviceType: loginDetails.deviceType,
});
const response = mapLoginResponse(user, key);
const cookie = this.getCookies(response, authType, loginDetails);
return { response, cookie };
}
private getCookies(loginResponse: LoginResponseDto, authType: AuthType, { isSecure }: LoginDetails) {
const maxAge = 400 * 24 * 3600; // 400 days
let authTypeCookie = '';
let accessTokenCookie = '';
if (isSecure) {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Secure; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
} else {
accessTokenCookie = `${IMMICH_ACCESS_COOKIE}=${loginResponse.accessToken}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
authTypeCookie = `${IMMICH_AUTH_TYPE_COOKIE}=${authType}; HttpOnly; Path=/; Max-Age=${maxAge}; SameSite=Lax;`;
}
return [accessTokenCookie, authTypeCookie];
}
}

View File

@@ -1,4 +1,6 @@
export * from './auth-user.dto';
export * from './change-password.dto';
export * from './login-credential.dto';
export * from './oauth-auth-code.dto';
export * from './oauth-config.dto';
export * from './sign-up.dto';

View File

@@ -1,5 +1,5 @@
export * from './auth.constant';
export * from './auth.core';
export * from './auth.service';
export * from './dto';
export * from './response-dto';
export * from './user-token.repository';

View File

@@ -2,4 +2,5 @@ export * from './admin-signup-response.dto';
export * from './auth-device-response.dto';
export * from './login-response.dto';
export * from './logout-response.dto';
export * from './oauth-config-response.dto';
export * from './validate-asset-token-response.dto';

View File

@@ -0,0 +1,191 @@
import { mimeTypes } from '@app/domain';
describe('mimeTypes', () => {
for (const { mimetype, extension } of [
// Please ensure this list is sorted.
{ mimetype: 'image/3fr', extension: '.3fr' },
{ mimetype: 'image/ari', extension: '.ari' },
{ mimetype: 'image/arw', extension: '.arw' },
{ mimetype: 'image/avif', extension: '.avif' },
{ mimetype: 'image/cap', extension: '.cap' },
{ mimetype: 'image/cin', extension: '.cin' },
{ mimetype: 'image/cr2', extension: '.cr2' },
{ mimetype: 'image/cr3', extension: '.cr3' },
{ mimetype: 'image/crw', extension: '.crw' },
{ mimetype: 'image/dcr', extension: '.dcr' },
{ mimetype: 'image/dng', extension: '.dng' },
{ mimetype: 'image/erf', extension: '.erf' },
{ mimetype: 'image/fff', extension: '.fff' },
{ mimetype: 'image/gif', extension: '.gif' },
{ mimetype: 'image/heic', extension: '.heic' },
{ mimetype: 'image/heif', extension: '.heif' },
{ mimetype: 'image/iiq', extension: '.iiq' },
{ mimetype: 'image/jpeg', extension: '.jpeg' },
{ mimetype: 'image/jpeg', extension: '.jpg' },
{ mimetype: 'image/jxl', extension: '.jxl' },
{ mimetype: 'image/k25', extension: '.k25' },
{ mimetype: 'image/kdc', extension: '.kdc' },
{ mimetype: 'image/mrw', extension: '.mrw' },
{ mimetype: 'image/nef', extension: '.nef' },
{ mimetype: 'image/orf', extension: '.orf' },
{ mimetype: 'image/ori', extension: '.ori' },
{ mimetype: 'image/pef', extension: '.pef' },
{ mimetype: 'image/png', extension: '.png' },
{ mimetype: 'image/raf', extension: '.raf' },
{ mimetype: 'image/raw', extension: '.raw' },
{ mimetype: 'image/rwl', extension: '.rwl' },
{ mimetype: 'image/sr2', extension: '.sr2' },
{ mimetype: 'image/srf', extension: '.srf' },
{ mimetype: 'image/srw', extension: '.srw' },
{ mimetype: 'image/tiff', extension: '.tiff' },
{ mimetype: 'image/webp', extension: '.webp' },
{ mimetype: 'image/x-adobe-dng', extension: '.dng' },
{ mimetype: 'image/x-arriflex-ari', extension: '.ari' },
{ mimetype: 'image/x-canon-cr2', extension: '.cr2' },
{ mimetype: 'image/x-canon-cr3', extension: '.cr3' },
{ mimetype: 'image/x-canon-crw', extension: '.crw' },
{ mimetype: 'image/x-epson-erf', extension: '.erf' },
{ mimetype: 'image/x-fuji-raf', extension: '.raf' },
{ mimetype: 'image/x-hasselblad-3fr', extension: '.3fr' },
{ mimetype: 'image/x-hasselblad-fff', extension: '.fff' },
{ mimetype: 'image/x-kodak-dcr', extension: '.dcr' },
{ mimetype: 'image/x-kodak-k25', extension: '.k25' },
{ mimetype: 'image/x-kodak-kdc', extension: '.kdc' },
{ mimetype: 'image/x-leica-rwl', extension: '.rwl' },
{ mimetype: 'image/x-minolta-mrw', extension: '.mrw' },
{ mimetype: 'image/x-nikon-nef', extension: '.nef' },
{ mimetype: 'image/x-olympus-orf', extension: '.orf' },
{ mimetype: 'image/x-olympus-ori', extension: '.ori' },
{ mimetype: 'image/x-panasonic-raw', extension: '.raw' },
{ mimetype: 'image/x-pentax-pef', extension: '.pef' },
{ mimetype: 'image/x-phantom-cin', extension: '.cin' },
{ mimetype: 'image/x-phaseone-cap', extension: '.cap' },
{ mimetype: 'image/x-phaseone-iiq', extension: '.iiq' },
{ mimetype: 'image/x-samsung-srw', extension: '.srw' },
{ mimetype: 'image/x-sigma-x3f', extension: '.x3f' },
{ mimetype: 'image/x-sony-arw', extension: '.arw' },
{ mimetype: 'image/x-sony-sr2', extension: '.sr2' },
{ mimetype: 'image/x-sony-srf', extension: '.srf' },
{ mimetype: 'image/x3f', extension: '.x3f' },
{ mimetype: 'video/3gpp', extension: '.3gp' },
{ mimetype: 'video/avi', extension: '.avi' },
{ mimetype: 'video/mp2t', extension: '.m2ts' },
{ mimetype: 'video/mp2t', extension: '.mts' },
{ mimetype: 'video/mp4', extension: '.mp4' },
{ mimetype: 'video/mpeg', extension: '.mpg' },
{ mimetype: 'video/msvideo', extension: '.avi' },
{ mimetype: 'video/quicktime', extension: '.mov' },
{ mimetype: 'video/vnd.avi', extension: '.avi' },
{ mimetype: 'video/webm', extension: '.webm' },
{ mimetype: 'video/x-flv', extension: '.flv' },
{ mimetype: 'video/x-matroska', extension: '.mkv' },
{ mimetype: 'video/x-ms-wmv', extension: '.wmv' },
{ mimetype: 'video/x-msvideo', extension: '.avi' },
]) {
it(`should map ${extension} to ${mimetype}`, async () => {
expect({ ...mimeTypes.image, ...mimeTypes.video }[extension]).toContain(mimetype);
});
}
describe('profile', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.profile);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.profile).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.profile);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
for (const [ext, v] of Object.entries(mimeTypes.profile)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('image', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.image);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.image).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.image);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only image mime types', () => {
const values = Object.values(mimeTypes.image).flat();
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('image/')));
});
for (const [ext, v] of Object.entries(mimeTypes.image)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('video', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.video);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.video).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.video);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only video mime types', () => {
const values = Object.values(mimeTypes.video).flat();
expect(values).toEqual(values.filter((mimeType) => mimeType.startsWith('video/')));
});
for (const [ext, v] of Object.entries(mimeTypes.video)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
describe('sidecar', () => {
it('should contain only lowercase mime types', () => {
const keys = Object.keys(mimeTypes.sidecar);
expect(keys).toEqual(keys.map((mimeType) => mimeType.toLowerCase()));
const values = Object.values(mimeTypes.sidecar).flat();
expect(values).toEqual(values.map((mimeType) => mimeType.toLowerCase()));
});
it('should be a sorted list', () => {
const keys = Object.keys(mimeTypes.sidecar);
// TODO: use toSorted in NodeJS 20.
expect(keys).toEqual([...keys].sort());
});
it('should contain only xml mime types', () => {
expect(Object.values(mimeTypes.sidecar).flat()).toEqual(['application/xml', 'text/xml']);
});
for (const [ext, v] of Object.entries(mimeTypes.sidecar)) {
it(`should lookup ${ext}`, () => {
expect(mimeTypes.lookup(`test.${ext}`)).toEqual(v[0]);
});
}
});
});

View File

@@ -30,70 +30,73 @@ export function assertMachineLearningEnabled() {
}
}
const profile: Record<string, string> = {
'.avif': 'image/avif',
'.dng': 'image/x-adobe-dng',
'.heic': 'image/heic',
'.heif': 'image/heif',
'.jpeg': 'image/jpeg',
'.jpg': 'image/jpeg',
'.png': 'image/png',
'.webp': 'image/webp',
const image: Record<string, string[]> = {
'.3fr': ['image/3fr', 'image/x-hasselblad-3fr'],
'.ari': ['image/ari', 'image/x-arriflex-ari'],
'.arw': ['image/arw', 'image/x-sony-arw'],
'.avif': ['image/avif'],
'.cap': ['image/cap', 'image/x-phaseone-cap'],
'.cin': ['image/cin', 'image/x-phantom-cin'],
'.cr2': ['image/cr2', 'image/x-canon-cr2'],
'.cr3': ['image/cr3', 'image/x-canon-cr3'],
'.crw': ['image/crw', 'image/x-canon-crw'],
'.dcr': ['image/dcr', 'image/x-kodak-dcr'],
'.dng': ['image/dng', 'image/x-adobe-dng'],
'.erf': ['image/erf', 'image/x-epson-erf'],
'.fff': ['image/fff', 'image/x-hasselblad-fff'],
'.gif': ['image/gif'],
'.heic': ['image/heic'],
'.heif': ['image/heif'],
'.iiq': ['image/iiq', 'image/x-phaseone-iiq'],
'.jpeg': ['image/jpeg'],
'.jpg': ['image/jpeg'],
'.jxl': ['image/jxl'],
'.k25': ['image/k25', 'image/x-kodak-k25'],
'.kdc': ['image/kdc', 'image/x-kodak-kdc'],
'.mrw': ['image/mrw', 'image/x-minolta-mrw'],
'.nef': ['image/nef', 'image/x-nikon-nef'],
'.orf': ['image/orf', 'image/x-olympus-orf'],
'.ori': ['image/ori', 'image/x-olympus-ori'],
'.pef': ['image/pef', 'image/x-pentax-pef'],
'.png': ['image/png'],
'.raf': ['image/raf', 'image/x-fuji-raf'],
'.raw': ['image/raw', 'image/x-panasonic-raw'],
'.rwl': ['image/rwl', 'image/x-leica-rwl'],
'.sr2': ['image/sr2', 'image/x-sony-sr2'],
'.srf': ['image/srf', 'image/x-sony-srf'],
'.srw': ['image/srw', 'image/x-samsung-srw'],
'.tiff': ['image/tiff'],
'.webp': ['image/webp'],
'.x3f': ['image/x3f', 'image/x-sigma-x3f'],
};
const image: Record<string, string> = {
...profile,
'.3fr': 'image/x-hasselblad-3fr',
'.ari': 'image/x-arriflex-ari',
'.arw': 'image/x-sony-arw',
'.cap': 'image/x-phaseone-cap',
'.cin': 'image/x-phantom-cin',
'.cr2': 'image/x-canon-cr2',
'.cr3': 'image/x-canon-cr3',
'.crw': 'image/x-canon-crw',
'.dcr': 'image/x-kodak-dcr',
'.erf': 'image/x-epson-erf',
'.fff': 'image/x-hasselblad-fff',
'.gif': 'image/gif',
'.iiq': 'image/x-phaseone-iiq',
'.k25': 'image/x-kodak-k25',
'.kdc': 'image/x-kodak-kdc',
'.mrw': 'image/x-minolta-mrw',
'.nef': 'image/x-nikon-nef',
'.orf': 'image/x-olympus-orf',
'.ori': 'image/x-olympus-ori',
'.pef': 'image/x-pentax-pef',
'.raf': 'image/x-fuji-raf',
'.raw': 'image/x-panasonic-raw',
'.rwl': 'image/x-leica-rwl',
'.sr2': 'image/x-sony-sr2',
'.srf': 'image/x-sony-srf',
'.srw': 'image/x-samsung-srw',
'.tiff': 'image/tiff',
'.x3f': 'image/x-sigma-x3f',
const profileExtensions = ['.avif', '.dng', '.heic', '.heif', '.jpeg', '.jpg', '.png', '.webp'];
const profile: Record<string, string[]> = Object.fromEntries(
Object.entries(image).filter(([key]) => profileExtensions.includes(key)),
);
const video: Record<string, string[]> = {
'.3gp': ['video/3gpp'],
'.avi': ['video/avi', 'video/msvideo', 'video/vnd.avi', 'video/x-msvideo'],
'.flv': ['video/x-flv'],
'.m2ts': ['video/mp2t'],
'.mkv': ['video/x-matroska'],
'.mov': ['video/quicktime'],
'.mp4': ['video/mp4'],
'.mpg': ['video/mpeg'],
'.mts': ['video/mp2t'],
'.webm': ['video/webm'],
'.wmv': ['video/x-ms-wmv'],
};
const video: Record<string, string> = {
'.3gp': 'video/3gpp',
'.avi': 'video/x-msvideo',
'.flv': 'video/x-flv',
'.mkv': 'video/x-matroska',
'.mov': 'video/quicktime',
'.mp2t': 'video/mp2t',
'.mp4': 'video/mp4',
'.mpeg': 'video/mpeg',
'.webm': 'video/webm',
'.wmv': 'video/x-ms-wmv',
const sidecar: Record<string, string[]> = {
'.xmp': ['application/xml', 'text/xml'],
};
const sidecar: Record<string, string> = {
'.xmp': 'application/xml',
};
const isType = (filename: string, r: Record<string, string[]>) => extname(filename).toLowerCase() in r;
const isType = (filename: string, lookup: Record<string, string>) => !!lookup[extname(filename).toLowerCase()];
const getType = (filename: string, lookup: Record<string, string>) => lookup[extname(filename).toLowerCase()];
const lookup = (filename: string) =>
getType(filename, { ...image, ...video, ...sidecar }) || 'application/octet-stream';
({ ...image, ...video, ...sidecar }[extname(filename).toLowerCase()]?.[0] ?? 'application/octet-stream');
export const mimeTypes = {
image,
@@ -107,14 +110,12 @@ export const mimeTypes = {
isVideo: (filename: string) => isType(filename, video),
lookup,
assetType: (filename: string) => {
const contentType = lookup(filename).split('/')[0];
switch (contentType) {
case 'image':
return AssetType.IMAGE;
case 'video':
return AssetType.VIDEO;
default:
return AssetType.OTHER;
const contentType = lookup(filename);
if (contentType.startsWith('image/')) {
return AssetType.IMAGE;
} else if (contentType.startsWith('video/')) {
return AssetType.VIDEO;
}
return AssetType.OTHER;
},
};

View File

@@ -7,7 +7,6 @@ import { FacialRecognitionService } from './facial-recognition';
import { JobService } from './job';
import { MediaService } from './media';
import { MetadataService } from './metadata';
import { OAuthService } from './oauth';
import { PartnerService } from './partner';
import { PersonService } from './person';
import { SearchService } from './search';
@@ -29,7 +28,6 @@ const providers: Provider[] = [
JobService,
MediaService,
MetadataService,
OAuthService,
PersonService,
PartnerService,
SearchService,

View File

@@ -13,7 +13,6 @@ export * from './facial-recognition';
export * from './job';
export * from './media';
export * from './metadata';
export * from './oauth';
export * from './partner';
export * from './person';
export * from './search';
@@ -25,4 +24,3 @@ export * from './storage-template';
export * from './system-config';
export * from './tag';
export * from './user';
export * from './user-token';

View File

@@ -1,2 +0,0 @@
export * from './oauth-auth-code.dto';
export * from './oauth-config.dto';

View File

@@ -1,4 +0,0 @@
export * from './dto';
export * from './oauth.constants';
export * from './oauth.service';
export * from './response-dto';

View File

@@ -1 +0,0 @@
export const MOBILE_REDIRECT = 'app.immich:/';

View File

@@ -1,107 +0,0 @@
import { SystemConfig } from '@app/infra/entities';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { ClientMetadata, custom, generators, Issuer, UserinfoResponse } from 'openid-client';
import { ISystemConfigRepository } from '../system-config';
import { SystemConfigCore } from '../system-config/system-config.core';
import { OAuthConfigDto } from './dto';
import { MOBILE_REDIRECT } from './oauth.constants';
import { OAuthConfigResponseDto } from './response-dto';
type OAuthProfile = UserinfoResponse & {
email: string;
};
@Injectable()
export class OAuthCore {
private readonly logger = new Logger(OAuthCore.name);
private configCore: SystemConfigCore;
constructor(configRepository: ISystemConfigRepository, private config: SystemConfig) {
this.configCore = new SystemConfigCore(configRepository);
custom.setHttpOptionsDefaults({
timeout: 30000,
});
this.configCore.config$.subscribe((config) => (this.config = config));
}
async generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
const response = {
enabled: this.config.oauth.enabled,
passwordLoginEnabled: this.config.passwordLogin.enabled,
};
if (!response.enabled) {
return response;
}
const { scope, buttonText, autoLaunch } = this.config.oauth;
const url = (await this.getClient()).authorizationUrl({
redirect_uri: this.normalize(dto.redirectUri),
scope,
state: generators.state(),
});
return { ...response, buttonText, url, autoLaunch };
}
async callback(url: string): Promise<OAuthProfile> {
const redirectUri = this.normalize(url.split('?')[0]);
const client = await this.getClient();
const params = client.callbackParams(url);
const tokens = await client.callback(redirectUri, params, { state: params.state });
return await client.userinfo<OAuthProfile>(tokens.access_token || '');
}
isAutoRegisterEnabled() {
return this.config.oauth.autoRegister;
}
asUser(profile: OAuthProfile) {
return {
firstName: profile.given_name || '',
lastName: profile.family_name || '',
email: profile.email,
oauthId: profile.sub,
};
}
async getLogoutEndpoint(): Promise<string | null> {
if (!this.config.oauth.enabled) {
return null;
}
return (await this.getClient()).issuer.metadata.end_session_endpoint || null;
}
private async getClient() {
const { enabled, clientId, clientSecret, issuerUrl } = this.config.oauth;
if (!enabled) {
throw new BadRequestException('OAuth2 is not enabled');
}
const metadata: ClientMetadata = {
client_id: clientId,
client_secret: clientSecret,
response_types: ['code'],
};
const issuer = await Issuer.discover(issuerUrl);
const algorithms = (issuer.id_token_signing_alg_values_supported || []) as string[];
if (algorithms[0] === 'HS256') {
metadata.id_token_signed_response_alg = algorithms[0];
}
return new issuer.Client(metadata);
}
private normalize(redirectUri: string) {
const isMobile = redirectUri.startsWith(MOBILE_REDIRECT);
const { mobileRedirectUri, mobileOverrideEnabled } = this.config.oauth;
if (isMobile && mobileOverrideEnabled && mobileRedirectUri) {
return mobileRedirectUri;
}
return redirectUri;
}
}

View File

@@ -1,217 +0,0 @@
import { SystemConfig, UserEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
authStub,
loginResponseStub,
newCryptoRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
newUserTokenRepositoryMock,
systemConfigStub,
userEntityStub,
userTokenEntityStub,
} from '@test';
import { generators, Issuer } from 'openid-client';
import { OAuthService } from '.';
import { LoginDetails } from '../auth';
import { ICryptoRepository } from '../crypto';
import { ISystemConfigRepository } from '../system-config';
import { IUserRepository } from '../user';
import { IUserTokenRepository } from '../user-token';
const email = 'user@immich.com';
const sub = 'my-auth-user-sub';
const loginDetails: LoginDetails = {
isSecure: true,
clientIp: '127.0.0.1',
deviceOS: '',
deviceType: '',
};
describe('OAuthService', () => {
let sut: OAuthService;
let userMock: jest.Mocked<IUserRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let configMock: jest.Mocked<ISystemConfigRepository>;
let userTokenMock: jest.Mocked<IUserTokenRepository>;
let callbackMock: jest.Mock;
let create: (config: SystemConfig) => OAuthService;
beforeEach(async () => {
callbackMock = jest.fn().mockReturnValue({ access_token: 'access-token' });
jest.spyOn(generators, 'state').mockReturnValue('state');
jest.spyOn(Issuer, 'discover').mockResolvedValue({
id_token_signing_alg_values_supported: ['HS256'],
Client: jest.fn().mockResolvedValue({
issuer: {
metadata: {
end_session_endpoint: 'http://end-session-endpoint',
},
},
authorizationUrl: jest.fn().mockReturnValue('http://authorization-url'),
callbackParams: jest.fn().mockReturnValue({ state: 'state' }),
callback: callbackMock,
userinfo: jest.fn().mockResolvedValue({ sub, email }),
}),
} as any);
cryptoMock = newCryptoRepositoryMock();
configMock = newSystemConfigRepositoryMock();
userMock = newUserRepositoryMock();
userTokenMock = newUserTokenRepositoryMock();
create = (config) => new OAuthService(cryptoMock, configMock, userMock, userTokenMock, config);
sut = create(systemConfigStub.disabled);
});
it('should be defined', () => {
expect(sut).toBeDefined();
});
describe('getMobileRedirect', () => {
it('should pass along the query params', () => {
expect(sut.getMobileRedirect('http://immich.app?code=123&state=456')).toEqual('app.immich:/?code=123&state=456');
});
it('should work if called without query params', () => {
expect(sut.getMobileRedirect('http://immich.app')).toEqual('app.immich:/?');
});
});
describe('generateConfig', () => {
it('should work when oauth is not configured', async () => {
await expect(sut.generateConfig({ redirectUri: 'http://callback' })).resolves.toEqual({
enabled: false,
passwordLoginEnabled: false,
});
});
it('should generate the config', async () => {
sut = create(systemConfigStub.enabled);
await expect(sut.generateConfig({ redirectUri: 'http://redirect' })).resolves.toEqual({
enabled: true,
buttonText: 'OAuth',
url: 'http://authorization-url',
autoLaunch: false,
passwordLoginEnabled: true,
});
});
});
describe('login', () => {
it('should throw an error if OAuth is not enabled', async () => {
await expect(sut.login({ url: '' }, loginDetails)).rejects.toBeInstanceOf(BadRequestException);
});
it('should not allow auto registering', async () => {
sut = create(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(null);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
});
it('should link an existing user', async () => {
sut = create(systemConfigStub.noAutoRegister);
userMock.getByEmail.mockResolvedValue(userEntityStub.user1);
userMock.update.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
expect(userMock.update).toHaveBeenCalledWith(userEntityStub.user1.id, { oauthId: sub });
});
it('should allow auto registering by default', async () => {
sut = create(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null);
userMock.getAdmin.mockResolvedValue(userEntityStub.user1);
userMock.create.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await expect(sut.login({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).resolves.toEqual(
loginResponseStub.user1oauth,
);
expect(userMock.getByEmail).toHaveBeenCalledTimes(2); // second call is for domain check before create
expect(userMock.create).toHaveBeenCalledTimes(1);
});
it('should use the mobile redirect override', async () => {
sut = create(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.login({ url: `app.immich:/?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
it('should use the mobile redirect override for ios urls with multiple slashes', async () => {
sut = create(systemConfigStub.override);
userMock.getByOAuthId.mockResolvedValue(userEntityStub.user1);
userTokenMock.create.mockResolvedValue(userTokenEntityStub.userToken);
await sut.login({ url: `app.immich:///?code=abc123` }, loginDetails);
expect(callbackMock).toHaveBeenCalledWith('http://mobile-redirect', { state: 'state' }, { state: 'state' });
});
});
describe('link', () => {
it('should link an account', async () => {
sut = create(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' });
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: sub });
});
it('should not link an already linked oauth.sub', async () => {
sut = create(systemConfigStub.enabled);
userMock.getByOAuthId.mockResolvedValue({ id: 'other-user' } as UserEntity);
await expect(sut.link(authStub.user1, { url: 'http://immich/user-settings?code=abc123' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(userMock.update).not.toHaveBeenCalled();
});
});
describe('unlink', () => {
it('should unlink an account', async () => {
sut = create(systemConfigStub.enabled);
userMock.update.mockResolvedValue(userEntityStub.user1);
await sut.unlink(authStub.user1);
expect(userMock.update).toHaveBeenCalledWith(authStub.user1.id, { oauthId: '' });
});
});
describe('getLogoutEndpoint', () => {
it('should return null if OAuth is not configured', async () => {
await expect(sut.getLogoutEndpoint()).resolves.toBeNull();
});
it('should get the session endpoint from the discovery document', async () => {
sut = create(systemConfigStub.enabled);
await expect(sut.getLogoutEndpoint()).resolves.toBe('http://end-session-endpoint');
});
});
});

View File

@@ -1,92 +0,0 @@
import { SystemConfig } from '@app/infra/entities';
import { BadRequestException, Inject, Injectable, Logger } from '@nestjs/common';
import { AuthType, AuthUserDto, LoginResponseDto } from '../auth';
import { AuthCore, LoginDetails } from '../auth/auth.core';
import { ICryptoRepository } from '../crypto';
import { INITIAL_SYSTEM_CONFIG, ISystemConfigRepository } from '../system-config';
import { IUserRepository, UserCore, UserResponseDto } from '../user';
import { IUserTokenRepository } from '../user-token';
import { OAuthCallbackDto, OAuthConfigDto } from './dto';
import { MOBILE_REDIRECT } from './oauth.constants';
import { OAuthCore } from './oauth.core';
import { OAuthConfigResponseDto } from './response-dto';
@Injectable()
export class OAuthService {
private authCore: AuthCore;
private oauthCore: OAuthCore;
private userCore: UserCore;
private readonly logger = new Logger(OAuthService.name);
constructor(
@Inject(ICryptoRepository) cryptoRepository: ICryptoRepository,
@Inject(ISystemConfigRepository) configRepository: ISystemConfigRepository,
@Inject(IUserRepository) userRepository: IUserRepository,
@Inject(IUserTokenRepository) userTokenRepository: IUserTokenRepository,
@Inject(INITIAL_SYSTEM_CONFIG) initialConfig: SystemConfig,
) {
this.authCore = new AuthCore(cryptoRepository, configRepository, userTokenRepository, initialConfig);
this.userCore = new UserCore(userRepository, cryptoRepository);
this.oauthCore = new OAuthCore(configRepository, initialConfig);
}
getMobileRedirect(url: string) {
return `${MOBILE_REDIRECT}?${url.split('?')[1] || ''}`;
}
generateConfig(dto: OAuthConfigDto): Promise<OAuthConfigResponseDto> {
return this.oauthCore.generateConfig(dto);
}
async login(
dto: OAuthCallbackDto,
loginDetails: LoginDetails,
): Promise<{ response: LoginResponseDto; cookie: string[] }> {
const profile = await this.oauthCore.callback(dto.url);
this.logger.debug(`Logging in with OAuth: ${JSON.stringify(profile)}`);
let user = await this.userCore.getByOAuthId(profile.sub);
// link existing user
if (!user) {
const emailUser = await this.userCore.getByEmail(profile.email);
if (emailUser) {
user = await this.userCore.updateUser(emailUser, emailUser.id, { oauthId: profile.sub });
}
}
// register new user
if (!user) {
if (!this.oauthCore.isAutoRegisterEnabled()) {
this.logger.warn(
`Unable to register ${profile.email}. To enable set OAuth Auto Register to true in admin settings.`,
);
throw new BadRequestException(`User does not exist and auto registering is disabled.`);
}
this.logger.log(`Registering new user: ${profile.email}/${profile.sub}`);
user = await this.userCore.createUser(this.oauthCore.asUser(profile));
}
return this.authCore.createLoginResponse(user, AuthType.OAUTH, loginDetails);
}
public async link(user: AuthUserDto, dto: OAuthCallbackDto): Promise<UserResponseDto> {
const { sub: oauthId } = await this.oauthCore.callback(dto.url);
const duplicate = await this.userCore.getByOAuthId(oauthId);
if (duplicate && duplicate.id !== user.id) {
this.logger.warn(`OAuth link account failed: sub is already linked to another user (${duplicate.email}).`);
throw new BadRequestException('This OAuth account has already been linked to another user.');
}
return this.userCore.updateUser(user, user.id, { oauthId });
}
public async unlink(user: AuthUserDto): Promise<UserResponseDto> {
return this.userCore.updateUser(user, user.id, { oauthId: '' });
}
public async getLogoutEndpoint(): Promise<string | null> {
return this.oauthCore.getLogoutEndpoint();
}
}

View File

@@ -1 +0,0 @@
export * from './oauth-config-response.dto';

View File

@@ -1,6 +1,7 @@
import { AssetFaceEntity, PersonEntity } from '@app/infra/entities';
import { IsOptional, IsString } from 'class-validator';
import { ValidateUUID } from '../domain.util';
import { Transform } from 'class-transformer';
import { IsBoolean, IsOptional, IsString } from 'class-validator';
import { toBoolean, ValidateUUID } from '../domain.util';
export class PersonUpdateDto {
/**
@@ -16,6 +17,13 @@ export class PersonUpdateDto {
@IsOptional()
@IsString()
featureFaceAssetId?: string;
/**
* Person visibility
*/
@IsOptional()
@IsBoolean()
isHidden?: boolean;
}
export class MergePersonDto {
@@ -23,10 +31,23 @@ export class MergePersonDto {
ids!: string[];
}
export class PersonSearchDto {
@IsBoolean()
@Transform(toBoolean)
withHidden?: boolean = false;
}
export class PersonResponseDto {
id!: string;
name!: string;
thumbnailPath!: string;
isHidden!: boolean;
}
export class PeopleResponseDto {
total!: number;
visible!: number;
people!: PersonResponseDto[];
}
export function mapPerson(person: PersonEntity): PersonResponseDto {
@@ -34,6 +55,7 @@ export function mapPerson(person: PersonEntity): PersonResponseDto {
id: person.id,
name: person.name,
thumbnailPath: person.thumbnailPath,
isHidden: person.isHidden,
};
}

View File

@@ -19,6 +19,7 @@ const responseDto: PersonResponseDto = {
id: 'person-1',
name: 'Person 1',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: false,
};
describe(PersonService.name, () => {
@@ -41,7 +42,37 @@ describe(PersonService.name, () => {
describe('getAll', () => {
it('should get all people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.noThumbnail]);
await expect(sut.getAll(authStub.admin)).resolves.toEqual([responseDto]);
await expect(sut.getAll(authStub.admin, { withHidden: undefined })).resolves.toEqual({
total: 1,
visible: 1,
people: [responseDto],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
it('should get all visible people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
await expect(sut.getAll(authStub.admin, { withHidden: false })).resolves.toEqual({
total: 2,
visible: 1,
people: [responseDto],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
it('should get all hidden and visible people with thumbnails', async () => {
personMock.getAll.mockResolvedValue([personStub.withName, personStub.hidden]);
await expect(sut.getAll(authStub.admin, { withHidden: true })).resolves.toEqual({
total: 2,
visible: 1,
people: [
responseDto,
{
id: 'person-1',
name: '',
thumbnailPath: '/path/to/thumbnail.jpg',
isHidden: true,
},
],
});
expect(personMock.getAll).toHaveBeenCalledWith(authStub.admin.id, { minimumFaceCount: 1 });
});
});
@@ -111,6 +142,21 @@ describe(PersonService.name, () => {
});
});
it('should update a person visibility', async () => {
personMock.getById.mockResolvedValue(personStub.hidden);
personMock.update.mockResolvedValue(personStub.withName);
personMock.getAssets.mockResolvedValue([assetEntityStub.image]);
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
expect(personMock.getById).toHaveBeenCalledWith('admin_id', 'person-1');
expect(personMock.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.SEARCH_INDEX_ASSET,
data: { ids: [assetEntityStub.image.id] },
});
});
it("should update a person's thumbnailPath", async () => {
personMock.getById.mockResolvedValue(personStub.withName);
personMock.getFaceById.mockResolvedValue(faceStub.face1);

View File

@@ -4,7 +4,14 @@ import { AuthUserDto } from '../auth';
import { mimeTypes } from '../domain.constant';
import { IJobRepository, JobName } from '../job';
import { ImmichReadStream, IStorageRepository } from '../storage';
import { mapPerson, MergePersonDto, PersonResponseDto, PersonUpdateDto } from './person.dto';
import {
mapPerson,
MergePersonDto,
PeopleResponseDto,
PersonResponseDto,
PersonSearchDto,
PersonUpdateDto,
} from './person.dto';
import { IPersonRepository, UpdateFacesData } from './person.repository';
@Injectable()
@@ -17,16 +24,21 @@ export class PersonService {
@Inject(IJobRepository) private jobRepository: IJobRepository,
) {}
async getAll(authUser: AuthUserDto): Promise<PersonResponseDto[]> {
async getAll(authUser: AuthUserDto, dto: PersonSearchDto): Promise<PeopleResponseDto> {
const people = await this.repository.getAll(authUser.id, { minimumFaceCount: 1 });
const named = people.filter((person) => !!person.name);
const unnamed = people.filter((person) => !person.name);
return (
[...named, ...unnamed]
// with thumbnails
.filter((person) => !!person.thumbnailPath)
.map((person) => mapPerson(person))
);
const persons: PersonResponseDto[] = [...named, ...unnamed]
// with thumbnails
.filter((person) => !!person.thumbnailPath)
.map((person) => mapPerson(person));
return {
people: persons.filter((person) => dto.withHidden || !person.isHidden),
total: persons.length,
visible: persons.filter((person: PersonResponseDto) => !person.isHidden).length,
};
}
getById(authUser: AuthUserDto, id: string): Promise<PersonResponseDto> {
@@ -50,8 +62,8 @@ export class PersonService {
async update(authUser: AuthUserDto, id: string, dto: PersonUpdateDto): Promise<PersonResponseDto> {
let person = await this.findOrFail(authUser, id);
if (dto.name) {
person = await this.repository.update({ id, name: dto.name });
if (dto.name != undefined || dto.isHidden !== undefined) {
person = await this.repository.update({ id, name: dto.name, isHidden: dto.isHidden });
const assets = await this.repository.getAssets(authUser.id, id);
const ids = assets.map((asset) => asset.id);
await this.jobRepository.queue({ name: JobName.SEARCH_INDEX_ASSET, data: { ids } });

View File

@@ -25,3 +25,9 @@ export class ServerStatsResponseDto {
})
usageByUser: UsageByUserDto[] = [];
}
export class ServerMediaTypesResponseDto {
video!: string[];
image!: string[];
sidecar!: string[];
}

View File

@@ -1,9 +1,15 @@
import { Inject, Injectable } from '@nestjs/common';
import { serverVersion } from '../domain.constant';
import { mimeTypes, serverVersion } from '../domain.constant';
import { asHumanReadable } from '../domain.util';
import { IStorageRepository, StorageCore, StorageFolder } from '../storage';
import { IUserRepository, UserStatsQueryResponse } from '../user';
import { ServerInfoResponseDto, ServerPingResponse, ServerStatsResponseDto, UsageByUserDto } from './response-dto';
import {
ServerInfoResponseDto,
ServerMediaTypesResponseDto,
ServerPingResponse,
ServerStatsResponseDto,
UsageByUserDto,
} from './response-dto';
@Injectable()
export class ServerInfoService {
@@ -60,4 +66,12 @@ export class ServerInfoService {
return serverStats;
}
getSupportedMediaTypes(): ServerMediaTypesResponseDto {
return {
video: [...Object.keys(mimeTypes.video)],
image: [...Object.keys(mimeTypes.image)],
sidecar: [...Object.keys(mimeTypes.sidecar)],
};
}
}

View File

@@ -4,7 +4,6 @@ import {
newStorageRepositoryMock,
newSystemConfigRepositoryMock,
newUserRepositoryMock,
systemConfigStub,
userEntityStub,
} from '@test';
import { when } from 'jest-when';
@@ -12,6 +11,7 @@ import { StorageTemplateService } from '.';
import { IAssetRepository } from '../asset';
import { IStorageRepository } from '../storage/storage.repository';
import { ISystemConfigRepository } from '../system-config';
import { defaults } from '../system-config/system-config.core';
import { IUserRepository } from '../user';
describe(StorageTemplateService.name, () => {
@@ -31,7 +31,7 @@ describe(StorageTemplateService.name, () => {
storageMock = newStorageRepositoryMock();
userMock = newUserRepositoryMock();
sut = new StorageTemplateService(assetMock, configMock, systemConfigStub.defaults, storageMock, userMock);
sut = new StorageTemplateService(assetMock, configMock, defaults, storageMock, userMock);
});
describe('handle template migration', () => {

View File

@@ -25,6 +25,9 @@ export class SystemConfigOAuthDto {
@IsString()
scope!: string;
@IsString()
storageLabelClaim!: string;
@IsString()
buttonText!: string;

Some files were not shown because too many files have changed in this diff Show More