Compare commits

...

41 Commits

Author SHA1 Message Date
Alex Tran
b49d9660f6 reduce widget rebuild 2023-11-02 21:33:33 -05:00
Jason Rasmussen
b58edae134 fix(web): timeline alignment (#4808) 2023-11-02 15:11:59 -05:00
martin
2b9f20a1b5 fix: update like status (#4803) 2023-11-02 14:43:27 -04:00
Alex
d5f8199655 fix(web): scrollbar not showing year (#4782)
* fix(web): scrollbar not showing year

* grammar

* fix test
2023-11-01 20:50:24 -05:00
Alex
d8903de92e docs: remove read-only related content (#4781)
* docs: remove read-only related content

* format

* broken link
2023-11-01 20:49:57 -05:00
Jason Rasmussen
1d35965d03 feat(web): shuffle slideshow order (#4277)
* feat(web): shuffle slideshow order

* Fix play/stop issues

* Enter/exit fullscreen mode
* Prevent navigation to the next asset after exiting slideshow mode

* Fix entering the slideshow mode from an album page

* Simplify markup of the AssetViewer

Group viewer area and navigation (prev/next/slideshow bar) controls together

* Select a random asset from a random bucket

* Preserve assets order in random mode

* Exit fullscreen mode only if it is active

* Extract SlideshowHistory class

* Use traditional functions instead of arrow functions

* Refactor SlideshowHistory class

* Extract SlideshowBar component

* Fix comments

* Hide Say something in slideshow mode

---------

Co-authored-by: brighteyed <sergey.kondrikov@gmail.com>
2023-11-01 21:34:30 -04:00
Alex
309bf1ad22 chore: post release tasks 2023-11-01 14:43:10 -05:00
Jason Rasmussen
0130591a0f fix: show/set activity like per user (#4775)
* fix: like per user

* chore: open api

* chore: e2e test for userId filtering
2023-11-01 11:49:12 -04:00
Alex The Bot
cf4ec06750 Version v1.84.0 2023-11-01 14:46:59 +00:00
Alex
e8712e6694 fix(server): import scheduler module (#4766) 2023-10-31 23:40:35 -05:00
martin
ce5966c23d feat(web,server): activity (#4682)
* feat: activity

* regenerate api

* fix: make asset owner unable to delete comment

* fix: merge

* fix: tests

* feat: use textarea instead of input

* fix: do actions only if the album is shared

* fix: placeholder opacity

* fix(web): improve messages UI

* fix(web): improve input message UI

* pr feedback

* fix: tests

* pr feedback

* pr feedback

* pr feedback

* fix permissions

* regenerate api

* pr feedback

* pr feedback

* multiple improvements on web

* fix: ui colors

* WIP

* chore: open api

* pr feedback

* fix: add comment

* chore: clean up

* pr feedback

* refactor: endpoints

* chore: open api

* fix: filter by type

* fix: e2e

* feat: e2e remove own comment

* fix: web tests

* remove console.log

* chore: cleanup

* fix: ui tweaks

* pr feedback

* fix web test

* fix: unit tests

* chore: remove unused code

* revert useless changes

* fix: grouping messages

* fix: remove nullable on updatedAt

* fix: text overflow

* styling

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-31 22:13:34 -05:00
Jason Rasmussen
68f6446718 fix(cli): ignore web socket when unavailable and skip metadata init (#4748) 2023-10-31 22:08:21 -05:00
Jason Rasmussen
197f336b5f fix(web): no preload repair report (#4749) 2023-10-31 20:37:32 +00:00
Daniel Dietzler
cd375a976e feat(server): custom library scanning interval (#4390)
* add automatic library scan config options

* add validation

* open api

* use CronJob instead of cron-validator

* fix tests

* catch potential error of the library scan initialization

* better description for input field

* move library scan job initialization to server app service

* fix tests

* add comments to all parameters of cronjob contructor

* make scan a child of a more general library object

* open api

* chore: cleanup

* move cronjob handling to job repoistory

* web: select for common cron expressions

* fix open api

* fix tests

* put scanning settings in nested accordion

* fix system config validation

* refactor, tests

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-31 15:19:12 -05:00
Jason Rasmussen
088d5addf2 refactor(server): user core (#4733) 2023-10-31 11:01:32 -04:00
shenlong
2377df9dae fix(mobile): store exposure time as string (#4589)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-10-31 05:33:45 -05:00
waclaw66
ad5ba82f50 fix(mobile): don't show lens info if it's not available (#4737) 2023-10-31 05:33:08 -05:00
Michael Manganiello
b6f18cbe81 fix(server): Correctly set album start and end dates (#4698)
* fix(server): Correctly set album start and end dates

Currently, the query that retrieves album assets uses
`ORDER BY assets.fileCreatedAt DESC`, which makes the existing logic
return the start/end dates reversed (with `startDate` being taken from
the first asset in the array).

Instead of using the index-based approach, this change iterates through
assets to get the min/max `fileCreatedAt`. This will avoid any future
issues, if the query ordering changes, or becomes customizable (e.g. in
case the user prefers to visualize older assets first).

* fix: Maintain constant cost and only swap variables if needed
2023-10-31 05:08:34 -05:00
Mert
87a0ba3db3 feat(ml): export clip models to ONNX and host models on Hugging Face (#4700)
* export clip models

* export to hf

refactored export code

* export mclip, general refactoring

cleanup

* updated conda deps

* do transforms with pillow and numpy, add tokenization config to export, general refactoring

* moved conda dockerfile, re-added poetry

* minor fixes

* updated link

* updated tests

* removed `requirements.txt` from workflow

* fixed mimalloc path

* removed torchvision

* cleaner np typing

* review suggestions

* update default model name

* update test
2023-10-31 05:02:04 -05:00
Jason Rasmussen
3212a47720 refactor(server): user profile picture (#4728) 2023-10-30 19:38:34 -04:00
Jason Rasmussen
431536cdbb refactor(server): user core (#4722) 2023-10-30 17:02:36 -04:00
martin
9a60578088 fix(web): multiple improvements for people page (1) (#4717)
* fix(web): multiple improvements for people page

* feat: better responsive icons
2023-10-30 14:40:28 -05:00
Jason Rasmussen
8dcd159bd6 chore(server): remove user count endpoint (#4724)
* chore: remove unused endpoint

* chore: open api
2023-10-30 19:29:18 +00:00
Skyler Mäntysaari
2f87463170 fix(server): better fix for the OAuth Discovery errors (#4695)
* fix(server/oauth): Handle errors from OAuth Discovery.

* fix(server/oauth): Better fix for OAuth discovery error.

* This doesn't break tests.

* Update server/tsconfig.json

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>

* Revert back to the mostly original way.

---------

Co-authored-by: Jason Rasmussen <jrasm91@gmail.com>
2023-10-30 13:22:30 -04:00
shenlong
9f56bf0ab9 refactor(mobile): app bar (#4687)
* refactor(mobile): add app bar to library and sharing

* mobile: add app bar dialog

* fix(mobile): refetch profile image only when path is changed

* mobile: add server url to dialog

* mobile: move trash to library app bar

* replace discord link with github

* user confirmation before sign out

* edit some styles

---------

Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-30 12:17:34 -05:00
Jason Rasmussen
603b056512 refactor(server): auth delete device (#4720)
* refactor(server): auth delete device

* fix: person e2e
2023-10-30 11:48:38 -04:00
Fynn Petersen-Frey
ce04e9e07a feat(server): hardware video acceleration for Rockchip SOCs via RKMPP (#4645)
* feat(server): hardware video acceleration for Rockchip SOCs via RKMPP

* add tests

* use LD_LIBRARY_PATH for custom ffmpeg

* incorporate review feedback

* code re-use for ffmpeg call

* review feedback
2023-10-30 09:39:37 -05:00
Alex
c54a188154 fix(web): sidebar setting not updating when there is a new property added to the data payload (#4708) 2023-10-30 09:17:37 -05:00
Mayuresh Dharwadkar
c77ba46d60 docs: fix typos (#4713) 2023-10-30 09:17:10 -05:00
martin
cc3149c520 fix(server): do not leak people (#4710) 2023-10-30 03:44:05 -05:00
shenlong
512f672e9e fix(mobile): cache key for assets from dto (#4699)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-10-29 15:28:54 -05:00
shenlong
b117985f66 fix(mobile): first char miss in new description (#4697)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-10-29 14:16:25 -05:00
Kalyani Mhala
b92a2b2a56 chore: add contribution section to readme (#4690)
* Update README.md

Successfully added contribution section to readme.md file.

* reordering

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-29 13:58:26 -05:00
Alex
a6f39bc74f fix(web): Improve UI/UX for shared link form (#4685)
* chore(web): Improve shared link form

* add verification for password

* improve ux
2023-10-29 13:50:43 -05:00
doggo
daad02504f feat(web): added toggle for Sharing button in the sidebar (#4674)
* Added toggle for Sharing button in the sidebar

* fix: format

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
2023-10-29 01:42:51 +00:00
jarvis2f
8a6889529c feat(server,web,mobile): Add optional password option for share links. (#4655)
* feat(server,web,mobile): Add optional password option for share links.

Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com>

* feat(server,web): Update shared-link.controller and page.svelte for improved cookie handling and metadata updates.

Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com>

---------

Signed-off-by: jarvis2f <137974272+jarvis2f@users.noreply.github.com>
2023-10-28 20:35:38 -05:00
Alex
b34cbd881a fix(web): scrollbar does not show all years (#4684) 2023-10-29 01:31:33 +00:00
martin
f6eaaab725 docs: update milestone page (#4683)
* docs: update milestone page

* docs: add 20k milestone
2023-10-28 20:20:05 -05:00
shenlong
2a2c74e081 fix(mobile): handle shared assets in viewer (#4679)
Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2023-10-28 14:48:30 -05:00
Skyler Mäntysaari
c653e0f261 fix(server/oauth): Handle errors from OAuth Discovery. (#4678) 2023-10-28 14:35:09 -05:00
martin
f0dd1d715a fix(web): table headers when there's no album (#4673) 2023-10-28 14:34:45 -05:00
264 changed files with 14303 additions and 4308 deletions

View File

@@ -166,7 +166,6 @@ jobs:
- name: Install dependencies
run: |
poetry install --with dev
poetry run pip install --no-deps -r requirements.txt
- name: Lint with ruff
run: |
poetry run ruff check --format=github app

View File

@@ -66,7 +66,7 @@ password: demo
Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
```
# Features
## Features
| Features | Mobile | Web |
| -------------------------------------------- | ------ | --- |
@@ -96,7 +96,7 @@ Spec: Free-tier Oracle VM - Amsterdam - 2.4Ghz quad-core ARM64 CPU, 24GB RAM
| Offline support | Yes | No |
| Read-only gallery | Yes | Yes |
# Support the project
## Support the project
I've committed to this project, and I will not stop. I will keep updating the docs, adding new features, and fixing bugs. But I can't do it alone. So I need your help to give me additional motivation to keep going.
@@ -104,10 +104,15 @@ As our hosts in the [selfhosted.show - In the episode 'The-organization-must-not
If you feel like this is the right cause and the app is something you are seeing yourself using for a long time, please consider supporting the project with the option below.
## Donation
### Donation
- [Monthly donation](https://github.com/sponsors/alextran1502) via GitHub Sponsors
- [One-time donation](https://github.com/sponsors/alextran1502?frequency=one-time&sponsor=alextran1502) via GitHub Sponsors
- [Librepay](https://liberapay.com/alex.tran1502/)
- [buymeacoffee](https://www.buymeacoffee.com/altran1502)
- Bitcoin: 1FvEp6P6NM8EZEkpGUFAN2LqJ1gxusNxZX
## Contributors
<a href="https://github.com/alextran1502/immich/graphs/contributors">
<img src="https://contrib.rocks/image?repo=immich-app/immich" width="100%"/>
</a>

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
@@ -99,6 +99,103 @@ export interface APIKeyUpdateDto {
*/
'name': string;
}
/**
*
* @export
* @interface ActivityCreateDto
*/
export interface ActivityCreateDto {
/**
*
* @type {string}
* @memberof ActivityCreateDto
*/
'albumId': string;
/**
*
* @type {string}
* @memberof ActivityCreateDto
*/
'assetId'?: string;
/**
*
* @type {string}
* @memberof ActivityCreateDto
*/
'comment'?: string;
/**
*
* @type {ReactionType}
* @memberof ActivityCreateDto
*/
'type': ReactionType;
}
/**
*
* @export
* @interface ActivityResponseDto
*/
export interface ActivityResponseDto {
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'assetId': string | null;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'comment'?: string | null;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'createdAt': string;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'id': string;
/**
*
* @type {string}
* @memberof ActivityResponseDto
*/
'type': ActivityResponseDtoTypeEnum;
/**
*
* @type {UserDto}
* @memberof ActivityResponseDto
*/
'user': UserDto;
}
export const ActivityResponseDtoTypeEnum = {
Comment: 'comment',
Like: 'like'
} as const;
export type ActivityResponseDtoTypeEnum = typeof ActivityResponseDtoTypeEnum[keyof typeof ActivityResponseDtoTypeEnum];
/**
*
* @export
* @interface ActivityStatisticsResponseDto
*/
export interface ActivityStatisticsResponseDto {
/**
*
* @type {number}
* @memberof ActivityStatisticsResponseDto
*/
'comments': number;
}
/**
*
* @export
@@ -2490,6 +2587,20 @@ export interface QueueStatusDto {
*/
'isPaused': boolean;
}
/**
*
* @export
* @enum {string}
*/
export const ReactionType = {
Comment: 'comment',
Like: 'like'
} as const;
export type ReactionType = typeof ReactionType[keyof typeof ReactionType];
/**
*
* @export
@@ -3038,6 +3149,12 @@ export interface SharedLinkCreateDto {
* @memberof SharedLinkCreateDto
*/
'expiresAt'?: string | null;
/**
*
* @type {string}
* @memberof SharedLinkCreateDto
*/
'password'?: string;
/**
*
* @type {boolean}
@@ -3089,6 +3206,12 @@ export interface SharedLinkEditDto {
* @memberof SharedLinkEditDto
*/
'expiresAt'?: string | null;
/**
*
* @type {string}
* @memberof SharedLinkEditDto
*/
'password'?: string;
/**
*
* @type {boolean}
@@ -3156,12 +3279,24 @@ export interface SharedLinkResponseDto {
* @memberof SharedLinkResponseDto
*/
'key': string;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'password': string | null;
/**
*
* @type {boolean}
* @memberof SharedLinkResponseDto
*/
'showMetadata': boolean;
/**
*
* @type {string}
* @memberof SharedLinkResponseDto
*/
'token'?: string | null;
/**
*
* @type {SharedLinkType}
@@ -3259,6 +3394,12 @@ export interface SystemConfigDto {
* @memberof SystemConfigDto
*/
'job': SystemConfigJobDto;
/**
*
* @type {SystemConfigLibraryDto}
* @memberof SystemConfigDto
*/
'library': SystemConfigLibraryDto;
/**
*
* @type {SystemConfigMachineLearningDto}
@@ -3510,6 +3651,38 @@ export interface SystemConfigJobDto {
*/
'videoConversion': JobSettingsDto;
}
/**
*
* @export
* @interface SystemConfigLibraryDto
*/
export interface SystemConfigLibraryDto {
/**
*
* @type {SystemConfigLibraryScanDto}
* @memberof SystemConfigLibraryDto
*/
'scan': SystemConfigLibraryScanDto;
}
/**
*
* @export
* @interface SystemConfigLibraryScanDto
*/
export interface SystemConfigLibraryScanDto {
/**
*
* @type {string}
* @memberof SystemConfigLibraryScanDto
*/
'cronExpression': string;
/**
*
* @type {boolean}
* @memberof SystemConfigLibraryScanDto
*/
'enabled': boolean;
}
/**
*
* @export
@@ -3940,6 +4113,7 @@ export const TranscodeHWAccel = {
Nvenc: 'nvenc',
Qsv: 'qsv',
Vaapi: 'vaapi',
Rkmpp: 'rkmpp',
Disabled: 'disabled'
} as const;
@@ -4188,15 +4362,39 @@ export interface UsageByUserDto {
/**
*
* @export
* @interface UserCountResponseDto
* @interface UserDto
*/
export interface UserCountResponseDto {
export interface UserDto {
/**
*
* @type {number}
* @memberof UserCountResponseDto
* @type {string}
* @memberof UserDto
*/
'userCount': number;
'email': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'firstName': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'id': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'lastName': string;
/**
*
* @type {string}
* @memberof UserDto
*/
'profileImagePath': string;
}
/**
*
@@ -4781,6 +4979,448 @@ export class APIKeyApi extends BaseAPI {
}
/**
* ActivityApi - axios parameter creator
* @export
*/
export const ActivityApiAxiosParamCreator = function (configuration?: Configuration) {
return {
/**
*
* @param {ActivityCreateDto} activityCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createActivity: async (activityCreateDto: ActivityCreateDto, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'activityCreateDto' is not null or undefined
assertParamExists('createActivity', 'activityCreateDto', activityCreateDto)
const localVarPath = `/activity`;
// 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: 'POST', ...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)
localVarHeaderParameter['Content-Type'] = 'application/json';
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
localVarRequestOptions.data = serializeDataIfNeeded(activityCreateDto, localVarRequestOptions, configuration)
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteActivity: async (id: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'id' is not null or undefined
assertParamExists('deleteActivity', 'id', id)
const localVarPath = `/activity/{id}`
.replace(`{${"id"}}`, encodeURIComponent(String(id)));
// 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: 'DELETE', ...baseOptions, ...options};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication cookie required
// authentication api_key required
await setApiKeyToObject(localVarHeaderParameter, "x-api-key", configuration)
// authentication bearer required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivities: async (albumId: string, assetId?: string, type?: ReactionType, userId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'albumId' is not null or undefined
assertParamExists('getActivities', 'albumId', albumId)
const localVarPath = `/activity`;
// 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)
if (albumId !== undefined) {
localVarQueryParameter['albumId'] = albumId;
}
if (assetId !== undefined) {
localVarQueryParameter['assetId'] = assetId;
}
if (type !== undefined) {
localVarQueryParameter['type'] = type;
}
if (userId !== undefined) {
localVarQueryParameter['userId'] = userId;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivityStatistics: async (albumId: string, assetId?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'albumId' is not null or undefined
assertParamExists('getActivityStatistics', 'albumId', albumId)
const localVarPath = `/activity/statistics`;
// 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)
if (albumId !== undefined) {
localVarQueryParameter['albumId'] = albumId;
}
if (assetId !== undefined) {
localVarQueryParameter['assetId'] = assetId;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
}
};
/**
* ActivityApi - functional programming interface
* @export
*/
export const ActivityApiFp = function(configuration?: Configuration) {
const localVarAxiosParamCreator = ActivityApiAxiosParamCreator(configuration)
return {
/**
*
* @param {ActivityCreateDto} activityCreateDto
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createActivity(activityCreateDto: ActivityCreateDto, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ActivityResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.createActivity(activityCreateDto, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deleteActivity(id: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.deleteActivity(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {ReactionType} [type]
* @param {string} [userId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getActivities(albumId: string, assetId?: string, type?: ReactionType, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<ActivityResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, userId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} albumId
* @param {string} [assetId]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getActivityStatistics(albumId: string, assetId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<ActivityStatisticsResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getActivityStatistics(albumId, assetId, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
}
};
/**
* ActivityApi - factory interface
* @export
*/
export const ActivityApiFactory = function (configuration?: Configuration, basePath?: string, axios?: AxiosInstance) {
const localVarFp = ActivityApiFp(configuration)
return {
/**
*
* @param {ActivityApiCreateActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig): AxiosPromise<ActivityResponseDto> {
return localVarFp.createActivity(requestParameters.activityCreateDto, options).then((request) => request(axios, basePath));
},
/**
*
* @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig): AxiosPromise<void> {
return localVarFp.deleteActivity(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise<Array<ActivityResponseDto>> {
return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(axios, basePath));
},
/**
*
* @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig): AxiosPromise<ActivityStatisticsResponseDto> {
return localVarFp.getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for createActivity operation in ActivityApi.
* @export
* @interface ActivityApiCreateActivityRequest
*/
export interface ActivityApiCreateActivityRequest {
/**
*
* @type {ActivityCreateDto}
* @memberof ActivityApiCreateActivity
*/
readonly activityCreateDto: ActivityCreateDto
}
/**
* Request parameters for deleteActivity operation in ActivityApi.
* @export
* @interface ActivityApiDeleteActivityRequest
*/
export interface ActivityApiDeleteActivityRequest {
/**
*
* @type {string}
* @memberof ActivityApiDeleteActivity
*/
readonly id: string
}
/**
* Request parameters for getActivities operation in ActivityApi.
* @export
* @interface ActivityApiGetActivitiesRequest
*/
export interface ActivityApiGetActivitiesRequest {
/**
*
* @type {string}
* @memberof ActivityApiGetActivities
*/
readonly albumId: string
/**
*
* @type {string}
* @memberof ActivityApiGetActivities
*/
readonly assetId?: string
/**
*
* @type {ReactionType}
* @memberof ActivityApiGetActivities
*/
readonly type?: ReactionType
/**
*
* @type {string}
* @memberof ActivityApiGetActivities
*/
readonly userId?: string
}
/**
* Request parameters for getActivityStatistics operation in ActivityApi.
* @export
* @interface ActivityApiGetActivityStatisticsRequest
*/
export interface ActivityApiGetActivityStatisticsRequest {
/**
*
* @type {string}
* @memberof ActivityApiGetActivityStatistics
*/
readonly albumId: string
/**
*
* @type {string}
* @memberof ActivityApiGetActivityStatistics
*/
readonly assetId?: string
}
/**
* ActivityApi - object-oriented interface
* @export
* @class ActivityApi
* @extends {BaseAPI}
*/
export class ActivityApi extends BaseAPI {
/**
*
* @param {ActivityApiCreateActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public createActivity(requestParameters: ActivityApiCreateActivityRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).createActivity(requestParameters.activityCreateDto, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {ActivityApiDeleteActivityRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public deleteActivity(requestParameters: ActivityApiDeleteActivityRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).deleteActivity(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {ActivityApiGetActivitiesRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {ActivityApiGetActivityStatisticsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ActivityApi
*/
public getActivityStatistics(requestParameters: ActivityApiGetActivityStatisticsRequest, options?: AxiosRequestConfig) {
return ActivityApiFp(this.configuration).getActivityStatistics(requestParameters.albumId, requestParameters.assetId, options).then((request) => request(this.axios, this.basePath));
}
}
/**
* AlbumApi - axios parameter creator
* @export
@@ -13690,11 +14330,13 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
},
/**
*
* @param {string} [password]
* @param {string} [token]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMySharedLink: async (key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
getMySharedLink: async (password?: string, token?: string, key?: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/shared-link/me`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@@ -13716,6 +14358,14 @@ export const SharedLinkApiAxiosParamCreator = function (configuration?: Configur
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (password !== undefined) {
localVarQueryParameter['password'] = password;
}
if (token !== undefined) {
localVarQueryParameter['token'] = token;
}
if (key !== undefined) {
localVarQueryParameter['key'] = key;
}
@@ -13959,12 +14609,14 @@ export const SharedLinkApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} [password]
* @param {string} [token]
* @param {string} [key]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMySharedLink(key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(key, options);
async getMySharedLink(password?: string, token?: string, key?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<SharedLinkResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMySharedLink(password, token, key, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@@ -14053,7 +14705,7 @@ export const SharedLinkApiFactory = function (configuration?: Configuration, bas
* @throws {RequiredError}
*/
getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig): AxiosPromise<SharedLinkResponseDto> {
return localVarFp.getMySharedLink(requestParameters.key, options).then((request) => request(axios, basePath));
return localVarFp.getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(axios, basePath));
},
/**
*
@@ -14142,6 +14794,20 @@ export interface SharedLinkApiCreateSharedLinkRequest {
* @interface SharedLinkApiGetMySharedLinkRequest
*/
export interface SharedLinkApiGetMySharedLinkRequest {
/**
*
* @type {string}
* @memberof SharedLinkApiGetMySharedLink
*/
readonly password?: string
/**
*
* @type {string}
* @memberof SharedLinkApiGetMySharedLink
*/
readonly token?: string
/**
*
* @type {string}
@@ -14274,7 +14940,7 @@ export class SharedLinkApi extends BaseAPI {
* @memberof SharedLinkApi
*/
public getMySharedLink(requestParameters: SharedLinkApiGetMySharedLinkRequest = {}, options?: AxiosRequestConfig) {
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.key, options).then((request) => request(this.axios, this.basePath));
return SharedLinkApiFp(this.configuration).getMySharedLink(requestParameters.password, requestParameters.token, requestParameters.key, options).then((request) => request(this.axios, this.basePath));
}
/**
@@ -15692,49 +16358,6 @@ export const UserApiAxiosParamCreator = 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 {boolean} [admin]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getUserCount: async (admin?: boolean, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
const localVarPath = `/user/count`;
// 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)
if (admin !== undefined) {
localVarQueryParameter['admin'] = admin;
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions = baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {...localVarHeaderParameter, ...headersFromBaseOptions, ...options.headers};
@@ -15909,16 +16532,6 @@ export const UserApiFp = function(configuration?: Configuration) {
const localVarAxiosArgs = await localVarAxiosParamCreator.getUserById(id, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {boolean} [admin]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getUserCount(admin?: boolean, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<UserCountResponseDto>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getUserCount(admin, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
*
* @param {string} id
@@ -16011,15 +16624,6 @@ export const UserApiFactory = function (configuration?: Configuration, basePath?
getUserById(requestParameters: UserApiGetUserByIdRequest, options?: AxiosRequestConfig): AxiosPromise<UserResponseDto> {
return localVarFp.getUserById(requestParameters.id, options).then((request) => request(axios, basePath));
},
/**
*
* @param {UserApiGetUserCountRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getUserCount(requestParameters: UserApiGetUserCountRequest = {}, options?: AxiosRequestConfig): AxiosPromise<UserCountResponseDto> {
return localVarFp.getUserCount(requestParameters.admin, options).then((request) => request(axios, basePath));
},
/**
*
* @param {UserApiRestoreUserRequest} requestParameters Request parameters.
@@ -16125,20 +16729,6 @@ export interface UserApiGetUserByIdRequest {
readonly id: string
}
/**
* Request parameters for getUserCount operation in UserApi.
* @export
* @interface UserApiGetUserCountRequest
*/
export interface UserApiGetUserCountRequest {
/**
*
* @type {boolean}
* @memberof UserApiGetUserCount
*/
readonly admin?: boolean
}
/**
* Request parameters for restoreUser operation in UserApi.
* @export
@@ -16250,17 +16840,6 @@ export class UserApi extends BaseAPI {
return UserApiFp(this.configuration).getUserById(requestParameters.id, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {UserApiGetUserCountRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof UserApi
*/
public getUserCount(requestParameters: UserApiGetUserCountRequest = {}, options?: AxiosRequestConfig) {
return UserApiFp(this.configuration).getUserCount(requestParameters.admin, options).then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {UserApiRestoreUserRequest} requestParameters Request parameters.

View File

@@ -4,7 +4,7 @@
* Immich
* Immich API
*
* The version of the OpenAPI document: 1.83.0
* The version of the OpenAPI document: 1.84.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.83.0
* The version of the OpenAPI document: 1.84.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.83.0
* The version of the OpenAPI document: 1.84.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.83.0
* The version of the OpenAPI document: 1.84.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).

24
docker/hwaccel-rkmpp.yml Normal file
View File

@@ -0,0 +1,24 @@
version: "3.8"
# Hardware acceleration for transcoding using RKMPP for Rockchip SOCs
# This is only needed if you want to use hardware acceleration for transcoding.
# Supported host OS is Ubuntu Jammy 22.04 with custom ffmpeg from ppa:liujianfeng1994/rockchip-multimedia
services:
hwaccel:
security_opt: # enables full access to /sys and /proc, still far better than privileged: true
- systempaths=unconfined
- apparmor=unconfined
group_add:
- video
devices:
- /dev/rga:/dev/rga
- /dev/dri:/dev/dri
- /dev/dma_heap:/dev/dma_heap
- /dev/mpp_service:/dev/mpp_service
volumes:
- /usr/bin/ffmpeg:/usr/bin/ffmpeg_mpp:ro
- /lib/aarch64-linux-gnu:/lib/ffmpeg-mpp:ro
- /lib/aarch64-linux-gnu/libblas.so.3:/lib/ffmpeg-mpp/libblas.so.3:ro # symlink is resolved by mounting
- /lib/aarch64-linux-gnu/liblapack.so.3:/lib/ffmpeg-mpp/liblapack.so.3:ro # symlink is resolved by mounting
- /lib/aarch64-linux-gnu/pulseaudio/libpulsecommon-15.99.so:/lib/ffmpeg-mpp/libpulsecommon-15.99.so:ro

View File

@@ -33,8 +33,6 @@ To be concise, Immich can now read in the gallery files, register the path into
- Only new files that are added to the gallery will be detected.
- Deleted and moved files will not be detected.
You can find more information on how to use the feature by reading the documentation [here](/docs/features/read-only-gallery).
## Memory feature
This is considered a fun feature that the team and I wanted to build for so long, but we had to put it off because of the refactoring of the code base. The code base is now in a good enough form to circle back and add more exciting features.

View File

@@ -12,6 +12,6 @@ The backend has an end-to-end test suite that can be called with `npm run test:e
Note that there is a bug in nodejs <20.8 that causes segmentation faults when running these tests. If you run into segfaults, ensure you are using at least version 20.8.
To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perfom the tests and exit.
To perform a full e2e test, you need to run e2e tests inside docker. The easiest way to do that is to run `make test-e2e` in the root directory. This will build and start a docker-compose consisting of the server, microservices, and a postgres database. It will then perform the tests and exit.
If you manually install the dependencies (see the DOCKERFILE) on your development machine, you can also run the full e2e tests manually by setting the `IMMICH_RUN_ALL_TESTS` environment value to true, i.e. `IMMICH_RUN_ALL_TESTS=true npm run test:e2e`.

View File

@@ -32,7 +32,6 @@ immich
| --server / -s | Immich's server address |
| --threads / -t | Number of threads to use (Default 5) |
| --album/ -al | Create albums for assets based on the parent folder or a given name |
| --import/ -i | Import gallery (assets are not uploaded) |
## Quick Start
@@ -108,70 +107,3 @@ npm run build
```bash title="Run the command"
node bin/index.js upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive your/asset/directory
```
---
## Importing existing libraries
If you do not wish to upload files into the server, existing files can be imported into the immich gallery through the use of the `--import` flag.
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive directory/ --import
```
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api file1.jpg file2.jpg --import
```
The `immich-server` and `immich-microservices` containers must be able to access the files, or directories at the path referenced in the command. The directories referenced must be set under a user's `External Path` setting. More detailed instructions can be found [here](/docs/features/read-only-gallery).
:::tip Matching volume references
The import command is most easily run on the machine running the immich service, as the path to the files on the machine running the command and the server much match identically.
If you are running immich within docker, the volume pointing to your existing library should be identical with your host machine.
```diff title="docker-compose.yml"
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "immich" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /path/to/media:/path/to/media
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "microservices" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /path/to/media:/path/to/media
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
```
The proper command for above would be as shown below. You should have access to `/path/to/media` exactly on the environment the CLI command is being run on
```
immich upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
```
If you are running the import using the docker command, please note that the volumes should point to the `/path/to/media` exactly on the environment the CLI command is being run on
```
docker run -it --rm -v "/path/to/media:/path/to/media" ghcr.io/immich-app/immich-cli:latest upload --key HFEJ38DNSDUEG --server http://192.168.1.216:2283/api --recursive /path/to/media --import
```
:::

View File

@@ -85,7 +85,7 @@ There is an automatic job that's run once a day and refreshes all modified files
Let's show a concrete example where we add an existing gallery to Immich. Here, we have the following folders we want to add:
- `/home/user/old-pics`: a folder contining childhood photos.
- `/home/user/old-pics`: a folder containing childhood photos.
- `/mnt/nas/christmas-trip`: photos from a christmas trip. The subfolder `/mnt/nas/christmas-trip/Raw` contains the raw files directly from the DSLR. We don't want to import the raw files to Immich
- `/mnt/media/videos`: Videos from the same christmas trip.

View File

@@ -1,97 +0,0 @@
# Read-only Gallery [Deprecated]
:::caution
This feature is being deprecated in favor of [Libraries](/docs/features/libraries.md).
:::
## Overview
This feature enables users to use an existing gallery without uploading the assets to Immich.
Upon syncing the file information, it will be read by Immich to generate supported files.
## Usage
:::tip Example scenario
On the VM/system that Immich is running, I have 2 galleries that I want to use with Immich.
- My gallery is stored at `/mnt/media/precious-memory`
- My wife's gallery is stored at `/mnt/media/childhood-memory`
We will use those values in the steps below.
:::
### Mount the gallery to the containers.
`immich-server` and `immich-microservices` containers will need access to the gallery. Mount the directory path as in the example below
```diff title="docker-compose.yml"
immich-server:
container_name: immich_server
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "immich" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
immich-microservices:
container_name: immich_microservices
image: ghcr.io/immich-app/immich-server:${IMMICH_VERSION:-release}
command: [ "start.sh", "microservices" ]
volumes:
- ${UPLOAD_LOCATION}:/usr/src/app/upload
+ - /mnt/media/precious-memory:/mnt/media/precious-memory:ro
+ - /mnt/media/childhood-memory:/mnt/media/childhood-memory:ro
env_file:
- .env
depends_on:
- redis
- database
- typesense
restart: always
```
:::tip
Internal and external path have to be identical.
:::
_Remember to bring the container down/up to register the changes. Make sure you can see the mounted path in the container._
### Register the path for the user.
This action is done by the admin of the instance.
- Navigate to `Administration > Users` page on the web.
- Click on the user edit button.
- Add the gallery path to the `External Path` field for the corresponding user and confirm the changes.
<img src={require('./img/me.png').default} width='33%' title='My Account Storage Path' />
<img src={require('./img/my-wife.png').default} width='33%' title='My Wifes Account Storage Path' />
### Sync with the CLI tool.
- Install or update the [CLI Tool](/docs/features/bulk-upload.md). The import feature is supported from version `v0.39.0` of the CLI
- Run the command below to sync the gallery with Immich.
```bash title="Import my gallery"
immich upload --key <my-api-key> --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import
```
```bash title="Import my wife gallery"
immich upload --key <my-wife-api-key> --server http://my-server-ip:2283/api /mnt/media/childhood-memory --recursive --import
```
The `--import` flag will tell Immich to import the files by path instead of uploading them.

View File

@@ -8,6 +8,7 @@ import {
mdiCheckAll,
mdiCheckboxMarked,
mdiCollage,
mdiContentCopy,
mdiDevices,
mdiFaceMan,
mdiFaceManOutline,
@@ -26,6 +27,7 @@ import {
mdiMerge,
mdiMonitor,
mdiMotionPlayOutline,
mdiPalette,
mdiPanVertical,
mdiPartyPopper,
mdiRaw,
@@ -47,6 +49,33 @@ import React from 'react';
import Timeline, { DateType, Item } from '../components/timeline';
const items: Item[] = [
{
icon: mdiStar,
description: 'Reach 20K Stars on GitHub!',
title: '20,000 Stars',
release: 'v1.83.0',
tag: 'v1.83.0',
date: new Date(2023, 9, 28),
dateType: DateType.RELEASE,
},
{
icon: mdiContentCopy,
title: 'Stack assets',
description: 'Manual asset stacking for grouping and hiding related assets in the main timeline.',
release: 'v1.83.0',
tag: 'v1.83.0',
date: new Date(2023, 9, 28),
dateType: DateType.RELEASE,
},
{
icon: mdiPalette,
title: 'Custom theme',
description: 'Apply your custom CSS for modifying fonts, colors, and styles in the web application.',
release: 'v1.83.0',
tag: 'v1.83.0',
date: new Date(2023, 9, 28),
dateType: DateType.RELEASE,
},
{
icon: mdiTrashCanOutline,
title: 'Trash Feature',
@@ -283,7 +312,7 @@ const items: Item[] = [
},
{
icon: mdiStar,
description: 'Reach 10K Starts on GitHub!',
description: 'Reach 10K Stars on GitHub!',
title: '10,000 Stars',
release: 'v1.54.0',
tag: 'v1.54.0',

View File

@@ -10,9 +10,8 @@ RUN poetry config installer.max-workers 10 && \
RUN python -m venv /opt/venv
ENV VIRTUAL_ENV="/opt/venv" PATH="/opt/venv/bin:${PATH}"
COPY poetry.lock pyproject.toml requirements.txt ./
COPY poetry.lock pyproject.toml ./
RUN poetry install --sync --no-interaction --no-ansi --no-root --only main
RUN pip install --no-deps -r requirements.txt
FROM python:3.11-slim-bookworm

View File

@@ -1,5 +1,6 @@
import json
from typing import Any, Iterator, TypeAlias
from pathlib import Path
from typing import Any, Iterator
from unittest import mock
import numpy as np
@@ -8,8 +9,7 @@ from fastapi.testclient import TestClient
from PIL import Image
from .main import app, init_state
ndarray: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
from .schemas import ndarray_f32
@pytest.fixture
@@ -18,13 +18,13 @@ def pil_image() -> Image.Image:
@pytest.fixture
def cv_image(pil_image: Image.Image) -> ndarray:
def cv_image(pil_image: Image.Image) -> ndarray_f32:
return np.asarray(pil_image)[:, :, ::-1] # PIL uses RGB while cv2 uses BGR
@pytest.fixture
def mock_get_model() -> Iterator[mock.Mock]:
with mock.patch("app.models.cache.InferenceModel.from_model_type", autospec=True) as mocked:
with mock.patch("app.models.cache.from_model_type", autospec=True) as mocked:
yield mocked
@@ -37,3 +37,25 @@ def deployed_app() -> TestClient:
@pytest.fixture(scope="session")
def responses() -> dict[str, Any]:
return json.load(open("responses.json", "r"))
@pytest.fixture(scope="session")
def clip_model_cfg() -> dict[str, Any]:
return {
"embed_dim": 512,
"vision_cfg": {"image_size": 224, "layers": 12, "width": 768, "patch_size": 32},
"text_cfg": {"context_length": 77, "vocab_size": 49408, "width": 512, "heads": 8, "layers": 12},
}
@pytest.fixture(scope="session")
def clip_preprocess_cfg() -> dict[str, Any]:
return {
"size": [224, 224],
"mode": "RGB",
"mean": [0.48145466, 0.4578275, 0.40821073],
"std": [0.26862954, 0.26130258, 0.27577711],
"interpolation": "bicubic",
"resize_mode": "shortest",
"fill_color": 0,
}

View File

@@ -1,3 +1,25 @@
from .clip import CLIPEncoder
from typing import Any
from app.schemas import ModelType
from .base import InferenceModel
from .clip import MCLIPEncoder, OpenCLIPEncoder, is_mclip, is_openclip
from .facial_recognition import FaceRecognizer
from .image_classification import ImageClassifier
def from_model_type(model_type: ModelType, model_name: str, **model_kwargs: Any) -> InferenceModel:
match model_type:
case ModelType.CLIP:
if is_openclip(model_name):
return OpenCLIPEncoder(model_name, **model_kwargs)
elif is_mclip(model_name):
return MCLIPEncoder(model_name, **model_kwargs)
else:
raise ValueError(f"Unknown CLIP model {model_name}")
case ModelType.FACIAL_RECOGNITION:
return FaceRecognizer(model_name, **model_kwargs)
case ModelType.IMAGE_CLASSIFICATION:
return ImageClassifier(model_name, **model_kwargs)
case _:
raise ValueError(f"Unknown model type {model_type}")

View File

@@ -25,7 +25,7 @@ class InferenceModel(ABC):
) -> None:
self.model_name = model_name
self.loaded = False
self._cache_dir = Path(cache_dir) if cache_dir is not None else get_cache_dir(model_name, self.model_type)
self._cache_dir = Path(cache_dir) if cache_dir is not None else None
self.providers = model_kwargs.pop("providers", ["CPUExecutionProvider"])
# don't pre-allocate more memory than needed
self.provider_options = model_kwargs.pop(
@@ -92,7 +92,7 @@ class InferenceModel(ABC):
@property
def cache_dir(self) -> Path:
return self._cache_dir
return self._cache_dir if self._cache_dir is not None else get_cache_dir(self.model_name, self.model_type)
@cache_dir.setter
def cache_dir(self, cache_dir: Path) -> None:

View File

@@ -4,6 +4,8 @@ from aiocache.backends.memory import SimpleMemoryCache
from aiocache.lock import OptimisticLock
from aiocache.plugins import BasePlugin, TimingPlugin
from app.models import from_model_type
from ..schemas import ModelType
from .base import InferenceModel
@@ -50,7 +52,7 @@ class ModelCache:
async with OptimisticLock(self.cache, key) as lock:
model = await self.cache.get(key)
if model is None:
model = InferenceModel.from_model_type(model_type, model_name, **model_kwargs)
model = from_model_type(model_type, model_name, **model_kwargs)
await lock.cas(model, ttl=self.ttl)
return model

View File

@@ -1,23 +1,24 @@
import os
import zipfile
import json
from abc import abstractmethod
from functools import cached_property
from io import BytesIO
from pathlib import Path
from typing import Any, Literal
import numpy as np
import onnxruntime as ort
import torch
from clip_server.model.clip import BICUBIC, _convert_image_to_rgb
from clip_server.model.clip_onnx import _MODELS, _S3_BUCKET_V2, CLIPOnnxModel, download_model
from clip_server.model.pretrained_models import _VISUAL_MODEL_IMAGE_SIZE
from clip_server.model.tokenization import Tokenizer
from huggingface_hub import snapshot_download
from PIL import Image
from torchvision.transforms import CenterCrop, Compose, Normalize, Resize, ToTensor
from transformers import AutoTokenizer
from app.config import log
from app.models.transforms import crop, get_pil_resampling, normalize, resize, to_numpy
from app.schemas import ModelType, ndarray_f32, ndarray_i32, ndarray_i64
from ..config import log
from ..schemas import ModelType
from .base import InferenceModel
class CLIPEncoder(InferenceModel):
class BaseCLIPEncoder(InferenceModel):
_model_type = ModelType.CLIP
def __init__(
@@ -27,48 +28,29 @@ class CLIPEncoder(InferenceModel):
mode: Literal["text", "vision"] | None = None,
**model_kwargs: Any,
) -> None:
if mode is not None and mode not in ("text", "vision"):
raise ValueError(f"Mode must be 'text', 'vision', or omitted; got '{mode}'")
if model_name not in _MODELS:
raise ValueError(f"Unknown model name {model_name}.")
self.mode = mode
super().__init__(model_name, cache_dir, **model_kwargs)
def _download(self) -> None:
models: tuple[tuple[str, str], tuple[str, str]] = _MODELS[self.model_name]
text_onnx_path = self.cache_dir / "textual.onnx"
vision_onnx_path = self.cache_dir / "visual.onnx"
if not text_onnx_path.is_file():
self._download_model(*models[0])
if not vision_onnx_path.is_file():
self._download_model(*models[1])
def _load(self) -> None:
if self.mode == "text" or self.mode is None:
log.debug(f"Loading clip text model '{self.model_name}'")
self.text_model = ort.InferenceSession(
self.cache_dir / "textual.onnx",
self.textual_path.as_posix(),
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
self.text_outputs = [output.name for output in self.text_model.get_outputs()]
self.tokenizer = Tokenizer(self.model_name)
if self.mode == "vision" or self.mode is None:
log.debug(f"Loading clip vision model '{self.model_name}'")
self.vision_model = ort.InferenceSession(
self.cache_dir / "visual.onnx",
self.visual_path.as_posix(),
sess_options=self.sess_options,
providers=self.providers,
provider_options=self.provider_options,
)
self.vision_outputs = [output.name for output in self.vision_model.get_outputs()]
image_size = _VISUAL_MODEL_IMAGE_SIZE[CLIPOnnxModel.get_model_name(self.model_name)]
self.transform = _transform_pil_image(image_size)
def _predict(self, image_or_text: Image.Image | str) -> list[float]:
if isinstance(image_or_text, bytes):
@@ -78,55 +60,163 @@ class CLIPEncoder(InferenceModel):
case Image.Image():
if self.mode == "text":
raise TypeError("Cannot encode image as text-only model")
pixel_values = self.transform(image_or_text)
assert isinstance(pixel_values, torch.Tensor)
pixel_values = torch.unsqueeze(pixel_values, 0).numpy()
outputs = self.vision_model.run(self.vision_outputs, {"pixel_values": pixel_values})
outputs = self.vision_model.run(None, self.transform(image_or_text))
case str():
if self.mode == "vision":
raise TypeError("Cannot encode text as vision-only model")
text_inputs: dict[str, torch.Tensor] = self.tokenizer(image_or_text)
inputs = {
"input_ids": text_inputs["input_ids"].int().numpy(),
"attention_mask": text_inputs["attention_mask"].int().numpy(),
}
outputs = self.text_model.run(self.text_outputs, inputs)
outputs = self.text_model.run(None, self.tokenize(image_or_text))
case _:
raise TypeError(f"Expected Image or str, but got: {type(image_or_text)}")
return outputs[0][0].tolist()
def _download_model(self, model_name: str, model_md5: str) -> bool:
# downloading logic is adapted from clip-server's CLIPOnnxModel class
download_model(
url=_S3_BUCKET_V2 + model_name,
target_folder=self.cache_dir.as_posix(),
md5sum=model_md5,
with_resume=True,
)
file = self.cache_dir / model_name.split("/")[1]
if file.suffix == ".zip":
with zipfile.ZipFile(file, "r") as zip_ref:
zip_ref.extractall(self.cache_dir)
os.remove(file)
return True
@abstractmethod
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
pass
@abstractmethod
def transform(self, image: Image.Image) -> dict[str, ndarray_f32]:
pass
@property
def textual_dir(self) -> Path:
return self.cache_dir / "textual"
@property
def visual_dir(self) -> Path:
return self.cache_dir / "visual"
@property
def model_cfg_path(self) -> Path:
return self.cache_dir / "config.json"
@property
def textual_path(self) -> Path:
return self.textual_dir / "model.onnx"
@property
def visual_path(self) -> Path:
return self.visual_dir / "model.onnx"
@property
def preprocess_cfg_path(self) -> Path:
return self.visual_dir / "preprocess_cfg.json"
@property
def cached(self) -> bool:
return (self.cache_dir / "textual.onnx").is_file() and (self.cache_dir / "visual.onnx").is_file()
return self.textual_path.is_file() and self.visual_path.is_file()
# same as `_transform_blob` without `_blob2image`
def _transform_pil_image(n_px: int) -> Compose:
return Compose(
[
Resize(n_px, interpolation=BICUBIC),
CenterCrop(n_px),
_convert_image_to_rgb,
ToTensor(),
Normalize(
(0.48145466, 0.4578275, 0.40821073),
(0.26862954, 0.26130258, 0.27577711),
),
]
)
class OpenCLIPEncoder(BaseCLIPEncoder):
def __init__(
self,
model_name: str,
cache_dir: str | None = None,
mode: Literal["text", "vision"] | None = None,
**model_kwargs: Any,
) -> None:
super().__init__(_clean_model_name(model_name), cache_dir, mode, **model_kwargs)
def _download(self) -> None:
snapshot_download(
f"immich-app/{self.model_name}",
cache_dir=self.cache_dir,
local_dir=self.cache_dir,
local_dir_use_symlinks=False,
)
def _load(self) -> None:
super()._load()
self.tokenizer = AutoTokenizer.from_pretrained(self.textual_dir)
self.sequence_length = self.model_cfg["text_cfg"]["context_length"]
self.size = (
self.preprocess_cfg["size"][0] if type(self.preprocess_cfg["size"]) == list else self.preprocess_cfg["size"]
)
self.resampling = get_pil_resampling(self.preprocess_cfg["interpolation"])
self.mean = np.array(self.preprocess_cfg["mean"], dtype=np.float32)
self.std = np.array(self.preprocess_cfg["std"], dtype=np.float32)
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
input_ids: ndarray_i64 = self.tokenizer(
text,
max_length=self.sequence_length,
return_tensors="np",
return_attention_mask=False,
padding="max_length",
truncation=True,
).input_ids
return {"text": input_ids.astype(np.int32)}
def transform(self, image: Image.Image) -> dict[str, ndarray_f32]:
image = resize(image, self.size)
image = crop(image, self.size)
image_np = to_numpy(image)
image_np = normalize(image_np, self.mean, self.std)
return {"image": np.expand_dims(image_np.transpose(2, 0, 1), 0)}
@cached_property
def model_cfg(self) -> dict[str, Any]:
return json.load(self.model_cfg_path.open())
@cached_property
def preprocess_cfg(self) -> dict[str, Any]:
return json.load(self.preprocess_cfg_path.open())
class MCLIPEncoder(OpenCLIPEncoder):
def tokenize(self, text: str) -> dict[str, ndarray_i32]:
tokens: dict[str, ndarray_i64] = self.tokenizer(text, return_tensors="np")
return {k: v.astype(np.int32) for k, v in tokens.items()}
_OPENCLIP_MODELS = {
"RN50__openai",
"RN50__yfcc15m",
"RN50__cc12m",
"RN101__openai",
"RN101__yfcc15m",
"RN50x4__openai",
"RN50x16__openai",
"RN50x64__openai",
"ViT-B-32__openai",
"ViT-B-32__laion2b_e16",
"ViT-B-32__laion400m_e31",
"ViT-B-32__laion400m_e32",
"ViT-B-32__laion2b-s34b-b79k",
"ViT-B-16__openai",
"ViT-B-16__laion400m_e31",
"ViT-B-16__laion400m_e32",
"ViT-B-16-plus-240__laion400m_e31",
"ViT-B-16-plus-240__laion400m_e32",
"ViT-L-14__openai",
"ViT-L-14__laion400m_e31",
"ViT-L-14__laion400m_e32",
"ViT-L-14__laion2b-s32b-b82k",
"ViT-L-14-336__openai",
"ViT-H-14__laion2b-s32b-b79k",
"ViT-g-14__laion2b-s12b-b42k",
}
_MCLIP_MODELS = {
"LABSE-Vit-L-14",
"XLM-Roberta-Large-Vit-B-32",
"XLM-Roberta-Large-Vit-B-16Plus",
"XLM-Roberta-Large-Vit-L-14",
}
def _clean_model_name(model_name: str) -> str:
return model_name.split("/")[-1].replace("::", "__")
def is_openclip(model_name: str) -> bool:
return _clean_model_name(model_name) in _OPENCLIP_MODELS
def is_mclip(model_name: str) -> bool:
return _clean_model_name(model_name) in _MCLIP_MODELS

View File

@@ -9,7 +9,8 @@ from insightface.model_zoo import ArcFaceONNX, RetinaFace
from insightface.utils.face_align import norm_crop
from insightface.utils.storage import BASE_REPO_URL, download_file
from ..schemas import ModelType
from app.schemas import ModelType, ndarray_f32
from .base import InferenceModel
@@ -68,7 +69,7 @@ class FaceRecognizer(InferenceModel):
)
self.rec_model.prepare(ctx_id=0)
def _predict(self, image: np.ndarray[int, np.dtype[Any]] | bytes) -> list[dict[str, Any]]:
def _predict(self, image: ndarray_f32 | bytes) -> list[dict[str, Any]]:
if isinstance(image, bytes):
image = cv2.imdecode(np.frombuffer(image, np.uint8), cv2.IMREAD_COLOR)
bboxes, kpss = self.det_model.detect(image)

View File

@@ -0,0 +1,35 @@
import numpy as np
from PIL import Image
from app.schemas import ndarray_f32
_PIL_RESAMPLING_METHODS = {resampling.name.lower(): resampling for resampling in Image.Resampling}
def resize(img: Image.Image, size: int) -> Image.Image:
if img.width < img.height:
return img.resize((size, int((img.height / img.width) * size)), resample=Image.BICUBIC)
else:
return img.resize((int((img.width / img.height) * size), size), resample=Image.BICUBIC)
# https://stackoverflow.com/a/60883103
def crop(img: Image.Image, size: int) -> Image.Image:
left = int((img.size[0] / 2) - (size / 2))
upper = int((img.size[1] / 2) - (size / 2))
right = left + size
lower = upper + size
return img.crop((left, upper, right, lower))
def to_numpy(img: Image.Image) -> ndarray_f32:
return np.asarray(img.convert("RGB")).astype(np.float32) / 255.0
def normalize(img: ndarray_f32, mean: float | ndarray_f32, std: float | ndarray_f32) -> ndarray_f32:
return (img - mean) / std
def get_pil_resampling(resample: str) -> Image.Resampling:
return _PIL_RESAMPLING_METHODS[resample.lower()]

View File

@@ -1,5 +1,7 @@
from enum import StrEnum
from typing import TypeAlias
import numpy as np
from pydantic import BaseModel
@@ -31,3 +33,8 @@ class ModelType(StrEnum):
IMAGE_CLASSIFICATION = "image-classification"
CLIP = "clip"
FACIAL_RECOGNITION = "facial-recognition"
ndarray_f32: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
ndarray_i64: TypeAlias = np.ndarray[int, np.dtype[np.int64]]
ndarray_i32: TypeAlias = np.ndarray[int, np.dtype[np.int32]]

View File

@@ -1,7 +1,8 @@
import json
import pickle
from io import BytesIO
from typing import Any, TypeAlias
from pathlib import Path
from typing import Any, Callable
from unittest import mock
import cv2
@@ -14,13 +15,11 @@ from pytest_mock import MockerFixture
from .config import settings
from .models.base import PicklableSessionOptions
from .models.cache import ModelCache
from .models.clip import CLIPEncoder
from .models.clip import OpenCLIPEncoder
from .models.facial_recognition import FaceRecognizer
from .models.image_classification import ImageClassifier
from .schemas import ModelType
ndarray: TypeAlias = np.ndarray[int, np.dtype[np.float32]]
class TestImageClassifier:
classifier_preds = [
@@ -56,30 +55,50 @@ class TestImageClassifier:
class TestCLIP:
embedding = np.random.rand(512).astype(np.float32)
cache_dir = Path("test_cache")
def test_basic_image(self, pil_image: Image.Image, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPEncoder, "download")
def test_basic_image(
self,
pil_image: Image.Image,
mocker: MockerFixture,
clip_model_cfg: dict[str, Any],
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
) -> None:
mocker.patch.object(OpenCLIPEncoder, "download")
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
mocked.return_value.run.return_value = [[self.embedding]]
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
assert clip_encoder.mode == "vision"
clip_encoder = OpenCLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="vision")
embedding = clip_encoder.predict(pil_image)
assert clip_encoder.mode == "vision"
assert isinstance(embedding, list)
assert len(embedding) == 512
assert len(embedding) == clip_model_cfg["embed_dim"]
assert all([isinstance(num, float) for num in embedding])
clip_encoder.vision_model.run.assert_called_once()
def test_basic_text(self, mocker: MockerFixture) -> None:
mocker.patch.object(CLIPEncoder, "download")
def test_basic_text(
self,
mocker: MockerFixture,
clip_model_cfg: dict[str, Any],
clip_preprocess_cfg: Callable[[Path], dict[str, Any]],
) -> None:
mocker.patch.object(OpenCLIPEncoder, "download")
mocker.patch.object(OpenCLIPEncoder, "model_cfg", clip_model_cfg)
mocker.patch.object(OpenCLIPEncoder, "preprocess_cfg", clip_preprocess_cfg)
mocker.patch("app.models.clip.AutoTokenizer.from_pretrained", autospec=True)
mocked = mocker.patch("app.models.clip.ort.InferenceSession", autospec=True)
mocked.return_value.run.return_value = [[self.embedding]]
clip_encoder = CLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
assert clip_encoder.mode == "text"
clip_encoder = OpenCLIPEncoder("ViT-B-32::openai", cache_dir="test_cache", mode="text")
embedding = clip_encoder.predict("test search query")
assert clip_encoder.mode == "text"
assert isinstance(embedding, list)
assert len(embedding) == 512
assert len(embedding) == clip_model_cfg["embed_dim"]
assert all([isinstance(num, float) for num in embedding])
clip_encoder.text_model.run.assert_called_once()

View File

@@ -0,0 +1,21 @@
FROM mambaorg/micromamba:bookworm-slim as builder
ENV NODE_ENV=production \
TRANSFORMERS_CACHE=/cache \
PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PATH="/opt/venv/bin:$PATH" \
PYTHONPATH=/usr/src
COPY --chown=$MAMBA_USER:$MAMBA_USER conda-lock.yml /tmp/conda-lock.yml
RUN micromamba install -y -n base -f /tmp/conda-lock.yml && \
micromamba remove -y -n base cxx-compiler && \
micromamba clean --all --yes
WORKDIR /usr/src/app
COPY --chown=$MAMBA_USER:$MAMBA_USER start.sh .
COPY --chown=$MAMBA_USER:$MAMBA_USER app .
ENTRYPOINT ["/usr/local/bin/_entrypoint.sh"]
CMD ["./start.sh"]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,15 @@
name: base
channels:
- conda-forge
platforms:
- linux-64
- linux-aarch64
dependencies:
- black
- conda-lock
- mypy
- pytest
- pytest-cov
- pytest-mock
- ruff
category: dev

View File

@@ -0,0 +1,25 @@
name: base
channels:
- conda-forge
- nvidia
- pytorch-nightly
platforms:
- linux-64
dependencies:
- cxx-compiler
- onnx==1.*
- onnxruntime==1.*
- open-clip-torch==2.*
- orjson==3.*
- pip
- python==3.11.*
- pytorch
- rich==13.*
- safetensors==0.*
- setuptools==68.*
- torchvision
- transformers==4.*
- pip:
- multilingual-clip
- onnx-simplifier
category: main

View File

@@ -0,0 +1,67 @@
import tempfile
import warnings
from pathlib import Path
import torch
from multilingual_clip.pt_multilingual_clip import MultilingualCLIP
from transformers import AutoTokenizer
from .openclip import OpenCLIPModelConfig
from .openclip import to_onnx as openclip_to_onnx
from .optimize import optimize
from .util import get_model_path
_MCLIP_TO_OPENCLIP = {
"M-CLIP/XLM-Roberta-Large-Vit-B-32": OpenCLIPModelConfig("ViT-B-32", "openai"),
"M-CLIP/XLM-Roberta-Large-Vit-B-16Plus": OpenCLIPModelConfig("ViT-B-16-plus-240", "laion400m_e32"),
"M-CLIP/LABSE-Vit-L-14": OpenCLIPModelConfig("ViT-L-14", "openai"),
"M-CLIP/XLM-Roberta-Large-Vit-L-14": OpenCLIPModelConfig("ViT-L-14", "openai"),
}
def to_onnx(
model_name: str,
output_dir_visual: Path | str,
output_dir_textual: Path | str,
) -> None:
textual_path = get_model_path(output_dir_textual)
with tempfile.TemporaryDirectory() as tmpdir:
model = MultilingualCLIP.from_pretrained(model_name, cache_dir=tmpdir)
AutoTokenizer.from_pretrained(model_name).save_pretrained(output_dir_textual)
for param in model.parameters():
param.requires_grad_(False)
export_text_encoder(model, textual_path)
openclip_to_onnx(_MCLIP_TO_OPENCLIP[model_name], output_dir_visual)
optimize(textual_path)
def export_text_encoder(model: MultilingualCLIP, output_path: Path | str) -> None:
output_path = Path(output_path)
def forward(self: MultilingualCLIP, input_ids: torch.Tensor, attention_mask: torch.Tensor) -> torch.Tensor:
embs = self.transformer(input_ids, attention_mask)[0]
embs = (embs * attention_mask.unsqueeze(2)).sum(dim=1) / attention_mask.sum(dim=1)[:, None]
embs = self.LinearTransformation(embs)
return torch.nn.functional.normalize(embs, dim=-1)
# unfortunately need to monkeypatch for tracing to work here
# otherwise it hits the 2GiB protobuf serialization limit
MultilingualCLIP.forward = forward
args = (torch.ones(1, 77, dtype=torch.int32), torch.ones(1, 77, dtype=torch.int32))
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
torch.onnx.export(
model,
args,
output_path.as_posix(),
input_names=["input_ids", "attention_mask"],
output_names=["text_embedding"],
opset_version=17,
dynamic_axes={
"input_ids": {0: "batch_size", 1: "sequence_length"},
"attention_mask": {0: "batch_size", 1: "sequence_length"},
},
)

View File

@@ -0,0 +1,109 @@
import tempfile
import warnings
from dataclasses import dataclass, field
from pathlib import Path
import open_clip
import torch
from transformers import AutoTokenizer
from .optimize import optimize
from .util import get_model_path, save_config
@dataclass
class OpenCLIPModelConfig:
name: str
pretrained: str
image_size: int = field(init=False)
sequence_length: int = field(init=False)
def __post_init__(self) -> None:
open_clip_cfg = open_clip.get_model_config(self.name)
if open_clip_cfg is None:
raise ValueError(f"Unknown model {self.name}")
self.image_size = open_clip_cfg["vision_cfg"]["image_size"]
self.sequence_length = open_clip_cfg["text_cfg"]["context_length"]
def to_onnx(
model_cfg: OpenCLIPModelConfig,
output_dir_visual: Path | str | None = None,
output_dir_textual: Path | str | None = None,
) -> None:
with tempfile.TemporaryDirectory() as tmpdir:
model = open_clip.create_model(
model_cfg.name,
pretrained=model_cfg.pretrained,
jit=False,
cache_dir=tmpdir,
require_pretrained=True,
)
text_vision_cfg = open_clip.get_model_config(model_cfg.name)
for param in model.parameters():
param.requires_grad_(False)
if output_dir_visual is not None:
output_dir_visual = Path(output_dir_visual)
visual_path = get_model_path(output_dir_visual)
save_config(open_clip.get_model_preprocess_cfg(model), output_dir_visual / "preprocess_cfg.json")
save_config(text_vision_cfg, output_dir_visual.parent / "config.json")
export_image_encoder(model, model_cfg, visual_path)
optimize(visual_path)
if output_dir_textual is not None:
output_dir_textual = Path(output_dir_textual)
textual_path = get_model_path(output_dir_textual)
tokenizer_name = text_vision_cfg["text_cfg"].get("hf_tokenizer_name", "openai/clip-vit-base-patch32")
AutoTokenizer.from_pretrained(tokenizer_name).save_pretrained(output_dir_textual)
export_text_encoder(model, model_cfg, textual_path)
optimize(textual_path)
def export_image_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, output_path: Path | str) -> None:
output_path = Path(output_path)
def encode_image(image: torch.Tensor) -> torch.Tensor:
return model.encode_image(image, normalize=True)
args = (torch.randn(1, 3, model_cfg.image_size, model_cfg.image_size),)
traced = torch.jit.trace(encode_image, args)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
torch.onnx.export(
traced,
args,
output_path.as_posix(),
input_names=["image"],
output_names=["image_embedding"],
opset_version=17,
dynamic_axes={"image": {0: "batch_size"}},
)
def export_text_encoder(model: open_clip.CLIP, model_cfg: OpenCLIPModelConfig, output_path: Path | str) -> None:
output_path = Path(output_path)
def encode_text(text: torch.Tensor) -> torch.Tensor:
return model.encode_text(text, normalize=True)
args = (torch.ones(1, model_cfg.sequence_length, dtype=torch.int32),)
traced = torch.jit.trace(encode_text, args)
with warnings.catch_warnings():
warnings.simplefilter("ignore", UserWarning)
torch.onnx.export(
traced,
args,
output_path.as_posix(),
input_names=["text"],
output_names=["text_embedding"],
opset_version=17,
dynamic_axes={"text": {0: "batch_size"}},
)

View File

@@ -0,0 +1,38 @@
from pathlib import Path
import onnx
import onnxruntime as ort
import onnxsim
def optimize_onnxsim(model_path: Path | str, output_path: Path | str) -> None:
model_path = Path(model_path)
output_path = Path(output_path)
model = onnx.load(model_path.as_posix())
model, check = onnxsim.simplify(model, skip_shape_inference=True)
assert check, "Simplified ONNX model could not be validated"
onnx.save(model, output_path.as_posix())
def optimize_ort(
model_path: Path | str,
output_path: Path | str,
level: ort.GraphOptimizationLevel = ort.GraphOptimizationLevel.ORT_ENABLE_BASIC,
) -> None:
model_path = Path(model_path)
output_path = Path(output_path)
sess_options = ort.SessionOptions()
sess_options.graph_optimization_level = level
sess_options.optimized_model_filepath = output_path.as_posix()
ort.InferenceSession(model_path.as_posix(), providers=["CPUExecutionProvider"], sess_options=sess_options)
def optimize(model_path: Path | str) -> None:
model_path = Path(model_path)
optimize_ort(model_path, model_path)
# onnxsim serializes large models as a blob, which uses much more memory when loading the model at runtime
if not any(file.name.startswith("Constant") for file in model_path.parent.iterdir()):
optimize_onnxsim(model_path, model_path)

View File

@@ -0,0 +1,15 @@
import json
from pathlib import Path
from typing import Any
def get_model_path(output_dir: Path | str) -> Path:
output_dir = Path(output_dir)
output_dir.mkdir(parents=True, exist_ok=True)
return output_dir / "model.onnx"
def save_config(config: Any, output_path: Path | str) -> None:
output_path = Path(output_path)
output_path.parent.mkdir(parents=True, exist_ok=True)
json.dump(config, output_path.open("w"))

View File

@@ -0,0 +1,76 @@
import gc
import os
from pathlib import Path
from tempfile import TemporaryDirectory
from huggingface_hub import create_repo, login, upload_folder
from models import mclip, openclip
from rich.progress import Progress
models = [
"RN50::openai",
"RN50::yfcc15m",
"RN50::cc12m",
"RN101::openai",
"RN101::yfcc15m",
"RN50x4::openai",
"RN50x16::openai",
"RN50x64::openai",
"ViT-B-32::openai",
"ViT-B-32::laion2b_e16",
"ViT-B-32::laion400m_e31",
"ViT-B-32::laion400m_e32",
"ViT-B-32::laion2b-s34b-b79k",
"ViT-B-16::openai",
"ViT-B-16::laion400m_e31",
"ViT-B-16::laion400m_e32",
"ViT-B-16-plus-240::laion400m_e31",
"ViT-B-16-plus-240::laion400m_e32",
"ViT-L-14::openai",
"ViT-L-14::laion400m_e31",
"ViT-L-14::laion400m_e32",
"ViT-L-14::laion2b-s32b-b82k",
"ViT-L-14-336::openai",
"ViT-H-14::laion2b-s32b-b79k",
"ViT-g-14::laion2b-s12b-b42k",
"M-CLIP/LABSE-Vit-L-14",
"M-CLIP/XLM-Roberta-Large-Vit-B-32",
"M-CLIP/XLM-Roberta-Large-Vit-B-16Plus",
"M-CLIP/XLM-Roberta-Large-Vit-L-14",
]
login(token=os.environ["HF_AUTH_TOKEN"])
with Progress() as progress:
task1 = progress.add_task("[green]Exporting models...", total=len(models))
task2 = progress.add_task("[yellow]Uploading models...", total=len(models))
with TemporaryDirectory() as tmp:
tmpdir = Path(tmp)
for model in models:
model_name = model.split("/")[-1].replace("::", "__")
config_path = tmpdir / model_name / "config.json"
def upload() -> None:
progress.update(task2, description=f"[yellow]Uploading {model_name}")
repo_id = f"immich-app/{model_name}"
create_repo(repo_id, exist_ok=True)
upload_folder(repo_id=repo_id, folder_path=tmpdir / model_name)
progress.update(task2, advance=1)
def export() -> None:
progress.update(task1, description=f"[green]Exporting {model_name}")
visual_dir = tmpdir / model_name / "visual"
textual_dir = tmpdir / model_name / "textual"
if model.startswith("M-CLIP"):
mclip.to_onnx(model, visual_dir, textual_dir)
else:
name, _, pretrained = model_name.partition("__")
openclip.to_onnx(openclip.OpenCLIPModelConfig(name, pretrained), visual_dir, textual_dir)
progress.update(task1, advance=1)
gc.collect()
export()
upload()

View File

@@ -1,11 +1,12 @@
from io import BytesIO
import json
from argparse import ArgumentParser
from io import BytesIO
from typing import Any
from locust import HttpUser, events, task
from locust.env import Environment
from PIL import Image
from argparse import ArgumentParser
byte_image = BytesIO()
@@ -14,11 +15,21 @@ def _(parser: ArgumentParser) -> None:
parser.add_argument("--tag-model", type=str, default="microsoft/resnet-50")
parser.add_argument("--clip-model", type=str, default="ViT-B-32::openai")
parser.add_argument("--face-model", type=str, default="buffalo_l")
parser.add_argument("--tag-min-score", type=int, default=0.0,
help="Returns all tags at or above this score. The default returns all tags.")
parser.add_argument("--face-min-score", type=int, default=0.034,
help=("Returns all faces at or above this score. The default returns 1 face per request; "
"setting this to 0 blows up the number of faces to the thousands."))
parser.add_argument(
"--tag-min-score",
type=int,
default=0.0,
help="Returns all tags at or above this score. The default returns all tags.",
)
parser.add_argument(
"--face-min-score",
type=int,
default=0.034,
help=(
"Returns all faces at or above this score. The default returns 1 face per request; "
"setting this to 0 blows up the number of faces to the thousands."
),
)
parser.add_argument("--image-size", type=int, default=1000)
@@ -62,7 +73,7 @@ class CLIPTextFormDataLoadTest(InferenceLoadTest):
("modelName", self.environment.parsed_options.clip_model),
("modelType", "clip"),
("options", json.dumps({"mode": "text"})),
("text", "test search query")
("text", "test search query"),
]
self.client.post("/predict", data=data)
@@ -88,5 +99,5 @@ class RecognitionFormDataLoadTest(InferenceLoadTest):
("options", json.dumps({"minScore": self.environment.parsed_options.face_min_score})),
]
files = {"image": self.data}
self.client.post("/predict", data=data, files=files)

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "machine-learning"
version = "1.83.0"
version = "1.84.0"
description = ""
authors = ["Hau Tran <alex.tran1502@gmail.com>"]
readme = "README.md"
@@ -9,8 +9,8 @@ packages = [{include = "app"}]
[tool.poetry.dependencies]
python = "^3.11"
torch = [
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.0.1", source = "pypi"},
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.0.1", source = "pytorch-cpu"}
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=2.1.0", source = "pypi"},
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=2.1.0", source = "pytorch-cpu"}
]
transformers = "^4.29.2"
onnxruntime = "^1.15.0"
@@ -22,14 +22,9 @@ uvicorn = {extras = ["standard"], version = "^0.22.0"}
pydantic = "^1.10.8"
aiocache = "^0.12.1"
optimum = "^1.9.1"
torchvision = [
{markers = "platform_machine == 'arm64' or platform_machine == 'aarch64'", version = "=0.15.2", source = "pypi"},
{markers = "platform_machine == 'amd64' or platform_machine == 'x86_64'", version = "=0.15.2", source = "pytorch-cpu"}
]
rich = "^13.4.2"
ftfy = "^6.1.1"
setuptools = "^68.0.0"
open-clip-torch = "^2.20.0"
python-multipart = "^0.0.6"
orjson = "^3.9.5"
safetensors = "0.3.2"
@@ -63,6 +58,7 @@ warn_redundant_casts = true
disallow_any_generics = true
check_untyped_defs = true
disallow_untyped_defs = true
ignore_missing_imports = true
[tool.pydantic-mypy]
init_forbid_extra = true
@@ -70,30 +66,6 @@ init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[[tool.mypy.overrides]]
module = [
"huggingface_hub",
"transformers",
"gunicorn",
"cv2",
"insightface.model_zoo",
"insightface.utils.face_align",
"insightface.utils.storage",
"onnxruntime",
"optimum",
"optimum.pipelines",
"optimum.onnxruntime",
"clip_server.model.clip",
"clip_server.model.clip_onnx",
"clip_server.model.pretrained_models",
"clip_server.model.tokenization",
"torchvision.transforms",
"aiocache.backends.memory",
"aiocache.lock",
"aiocache.plugins"
]
ignore_missing_imports = true
[tool.ruff]
line-length = 120
target-version = "py311"

View File

@@ -1,2 +0,0 @@
# requirements to be installed with `--no-deps` flag
clip-server==0.8.*

View File

@@ -35,8 +35,8 @@ platform :android do
task: 'bundle',
build_type: 'Release',
properties: {
"android.injected.version.code" => 107,
"android.injected.version.name" => "1.83.0",
"android.injected.version.code" => 108,
"android.injected.version.name" => "1.84.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

@@ -1,2 +1,2 @@
* User can now download assets to local device
* Increased the font size for curated image thumbnail information on the seach page
* Increased the font size for curated image thumbnail information on the search page

View File

@@ -5,17 +5,17 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000269">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000625">
</testcase>
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="81.160108">
<testcase classname="fastlane.lanes" name="1: bundleRelease" time="70.943413">
</testcase>
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="39.176668">
<testcase classname="fastlane.lanes" name="2: upload_to_play_store" time="30.374484">
</testcase>

View File

@@ -253,6 +253,8 @@
"profile_drawer_settings": "Settings",
"profile_drawer_sign_out": "Sign Out",
"profile_drawer_trash": "Trash",
"profile_drawer_documentation": "Documentation",
"profile_drawer_github": "GitHub",
"recently_added_page_title": "Recently Added",
"search_bar_hint": "Search your photos",
"search_page_categories": "Categories",
@@ -277,6 +279,7 @@
"select_user_for_sharing_page_share_suggestions": "Suggestions",
"server_info_box_app_version": "App Version",
"server_info_box_server_version": "Server Version",
"server_info_box_server_url": "Server URL",
"setting_image_viewer_help": "The detail viewer loads the small thumbnail first, then loads the medium-size preview (if enabled), finally loads the original (if enabled).",
"setting_image_viewer_original_subtitle": "Enable to load the original full-resolution image (large!). Disable to reduce data usage (both network and on device cache).",
"setting_image_viewer_original_title": "Load original image",
@@ -311,6 +314,8 @@
"shared_link_edit_change_expiry": "Change expiration time",
"shared_link_edit_description": "Description",
"shared_link_edit_description_hint": "Enter the share description",
"shared_link_edit_password": "Password",
"shared_link_edit_password_hint": "Enter the share password",
"shared_link_edit_show_meta": "Show metadata",
"shared_link_edit_submit_button": "Update link",
"shared_link_empty": "You don't have any shared links",
@@ -364,5 +369,8 @@
"viewer_unstack": "Un-Stack",
"cache_settings_tile_title": "Local Storage",
"cache_settings_tile_subtitle": "Control the local storage behaviour",
"viewer_stack_use_as_main_asset": "Use as Main Asset"
"viewer_stack_use_as_main_asset": "Use as Main Asset",
"app_bar_signout_dialog_title": "Sign out",
"app_bar_signout_dialog_content": "Are you sure you wanna sign out?",
"app_bar_signout_dialog_ok": "Yes"
}

View File

@@ -28,6 +28,12 @@ PODS:
- Flutter
- isar_flutter_libs (1.0.0):
- Flutter
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -42,6 +48,8 @@ PODS:
- FlutterMacOS
- ReachabilitySwift (5.0.0)
- SAMKeychain (1.5.3)
- screen_brightness_ios (0.1.0):
- Flutter
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -55,6 +63,8 @@ PODS:
- Flutter
- video_player_avfoundation (0.0.1):
- Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
@@ -71,16 +81,21 @@ DEPENDENCIES:
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- photo_manager (from `.symlinks/plugins/photo_manager/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
@@ -115,6 +130,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/integration_test/ios"
isar_flutter_libs:
:path: ".symlinks/plugins/isar_flutter_libs/ios"
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -125,6 +146,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
photo_manager:
:path: ".symlinks/plugins/photo_manager/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -135,6 +158,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/url_launcher_ios/ios"
video_player_avfoundation:
:path: ".symlinks/plugins/video_player_avfoundation/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
@@ -152,6 +177,9 @@ SPEC CHECKSUMS:
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
integration_test: 13825b8a9334a850581300559b8839134b124670
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
@@ -159,14 +187,16 @@ SPEC CHECKSUMS:
photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028
shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126
sqflite: 31f7eba61e3074736dff8807a9b41581e4f7f15a
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 8b09852c8876491e4b6d179e17dfe2a0b5f60d47
PODFILE CHECKSUM: 599d8aeb73728400c15364e734525722250a5382
COCOAPODS: 1.12.1
COCOAPODS: 1.11.3

View File

@@ -379,7 +379,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 124;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -515,7 +515,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 124;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@@ -543,7 +543,7 @@
CLANG_ENABLE_MODULES = YES;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 118;
CURRENT_PROJECT_VERSION = 124;
DEVELOPMENT_TEAM = 2F67MQ8R79;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;

View File

@@ -59,11 +59,11 @@
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.78.1</string>
<string>1.84.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>118</string>
<string>124</string>
<key>FLTEnableImpeller</key>
<true />
<key>ITSAppUsesNonExemptEncryption</key>

View File

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

View File

@@ -5,32 +5,32 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000256">
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000253">
</testcase>
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="7.645306">
<testcase classname="fastlane.lanes" name="1: increment_version_number" time="0.181977">
</testcase>
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="4.669798">
<testcase classname="fastlane.lanes" name="2: latest_testflight_build_number" time="16.12614">
</testcase>
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="2.218788">
<testcase classname="fastlane.lanes" name="3: increment_build_number" time="0.162663">
</testcase>
<testcase classname="fastlane.lanes" name="4: build_app" time="97.596654">
<testcase classname="fastlane.lanes" name="4: build_app" time="145.399278">
</testcase>
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="89.490906">
<testcase classname="fastlane.lanes" name="5: upload_to_testflight" time="61.317235">
</testcase>

View File

@@ -35,6 +35,7 @@ import 'package:immich_mobile/utils/immich_app_theme.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
import 'package:media_kit/media_kit.dart';
import 'package:path_provider/path_provider.dart';
void main() async {
@@ -49,6 +50,7 @@ void main() async {
Future<void> initApp() async {
await EasyLocalization.ensureInitialized();
MediaKit.ensureInitialized();
if (kReleaseMode && Platform.isAndroid) {
try {

View File

@@ -265,6 +265,7 @@ class AlbumViewerPage extends HookConsumerWidget {
if (data.isRemote) buildControlButton(data),
],
),
isOwner: userId == data.ownerId,
),
),
),

View File

@@ -10,12 +10,16 @@ import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
class LibraryPage extends HookConsumerWidget {
const LibraryPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider);
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
var settings = ref.watch(appSettingsServiceProvider);
@@ -28,21 +32,6 @@ class LibraryPage extends HookConsumerWidget {
[],
);
AppBar buildAppBar() {
return AppBar(
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
),
),
);
}
final selectedAlbumSortOrder =
useState(settings.getSetting(AppSettingsEnum.selectedAlbumSortOrder));
@@ -236,8 +225,23 @@ class LibraryPage extends HookConsumerWidget {
final local = albums.where((a) => a.isLocal).toList();
Widget? shareTrashButton() {
return trashEnabled
? InkWell(
onTap: () => AutoRouter.of(context).push(const TrashRoute()),
borderRadius: BorderRadius.circular(12),
child: const Icon(
Icons.delete_rounded,
size: 25,
),
)
: null;
}
return Scaffold(
appBar: buildAppBar(),
appBar: ImmichAppBar(
action: shareTrashButton(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(

View File

@@ -10,6 +10,7 @@ import 'package:immich_mobile/modules/partner/ui/partner_list.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class SharingPage extends HookConsumerWidget {
@@ -167,32 +168,6 @@ class SharingPage extends HookConsumerWidget {
);
}
AppBar buildAppBar() {
return AppBar(
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
),
),
actions: [
IconButton(
splashRadius: 25,
iconSize: 20,
icon: const Icon(
Icons.swap_horizontal_circle_outlined,
size: 20,
),
onPressed: () => AutoRouter.of(context).push(const PartnerRoute()),
),
],
);
}
buildEmptyListIndication() {
return SliverToBoxAdapter(
child: Padding(
@@ -241,8 +216,21 @@ class SharingPage extends HookConsumerWidget {
);
}
Widget sharePartnerButton() {
return InkWell(
onTap: () => AutoRouter.of(context).push(const PartnerRoute()),
borderRadius: BorderRadius.circular(12),
child: const Icon(
Icons.swap_horizontal_circle_rounded,
size: 25,
),
);
}
return Scaffold(
appBar: buildAppBar(),
appBar: ImmichAppBar(
action: sharePartnerButton(),
),
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: buildTopBottons()),

View File

@@ -31,7 +31,14 @@ class DescriptionInput extends HookConsumerWidget {
final owner = ref.watch(currentUserProvider);
final hasError = useState(false);
controller.text = description;
useEffect(
() {
controller.text = description;
isTextEmpty.value = description.isEmpty;
return null;
},
[description],
);
submitDescription(String description) async {
hasError.value = false;

View File

@@ -297,9 +297,9 @@ class ExifBottomSheet extends HookConsumerWidget {
fontWeight: FontWeight.bold,
),
),
subtitle: Text(
subtitle: exifInfo.f != null || exifInfo.exposureSeconds != null || exifInfo.mm != null || exifInfo.iso != null ? Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ",
),
) : null,
),
],
);

View File

@@ -15,6 +15,7 @@ class TopControlAppBar extends HookConsumerWidget {
required this.isPlayingMotionVideo,
required this.onFavorite,
required this.onUploadPressed,
required this.isOwner,
}) : super(key: key);
final Asset asset;
@@ -25,6 +26,7 @@ class TopControlAppBar extends HookConsumerWidget {
final VoidCallback onAddToAlbumPressed;
final Function(Asset) onFavorite;
final bool isPlayingMotionVideo;
final bool isOwner;
@override
Widget build(BuildContext context, WidgetRef ref) {
@@ -123,11 +125,11 @@ class TopControlAppBar extends HookConsumerWidget {
size: iconSize,
),
actions: [
if (asset.isRemote) buildFavoriteButton(a),
if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal) buildDownloadButton(),
if (asset.isRemote) buildAddToAlbumButtom(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && isOwner) buildAddToAlbumButtom(),
buildMoreInfoButton(),
],
);

View File

@@ -48,6 +48,7 @@ class GalleryViewerPage extends HookConsumerWidget {
final int initialIndex;
final int heroOffset;
final bool showStack;
final bool isOwner;
GalleryViewerPage({
super.key,
@@ -56,6 +57,7 @@ class GalleryViewerPage extends HookConsumerWidget {
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
this.isOwner = true,
}) : controller = PageController(initialPage: initialIndex);
final PageController controller;
@@ -88,7 +90,7 @@ class GalleryViewerPage extends HookConsumerWidget {
: <Asset>[];
final stackElements = showStack ? [currentAsset, ...stack] : <Asset>[];
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromResponse = currentAsset.id == Isar.autoIncrement;
final isFromDto = currentAsset.id == Isar.autoIncrement;
Asset asset() => stackIndex.value == -1
? currentAsset
@@ -334,6 +336,7 @@ class GalleryViewerPage extends HookConsumerWidget {
child: Container(
color: Colors.black.withOpacity(0.4),
child: TopControlAppBar(
isOwner: isOwner,
isPlayingMotionVideo: isPlayingMotionVideo.value,
asset: asset(),
onMoreInfoPressed: showInfo,
@@ -573,35 +576,50 @@ class GalleryViewerPage extends HookConsumerWidget {
label: 'control_bottom_app_bar_share'.tr(),
tooltip: 'control_bottom_app_bar_share'.tr(),
),
asset().isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'control_bottom_app_bar_unarchive'.tr(),
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'control_bottom_app_bar_archive'.tr(),
tooltip: 'control_bottom_app_bar_archive'.tr(),
),
if (stack.isNotEmpty)
if (isOwner)
asset().isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'control_bottom_app_bar_unarchive'.tr(),
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'control_bottom_app_bar_archive'.tr(),
tooltip: 'control_bottom_app_bar_archive'.tr(),
),
if (isOwner && stack.isNotEmpty)
BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined),
label: 'control_bottom_app_bar_stack'.tr(),
tooltip: 'control_bottom_app_bar_stack'.tr(),
),
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
if (isOwner)
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'control_bottom_app_bar_delete'.tr(),
tooltip: 'control_bottom_app_bar_delete'.tr(),
),
if (!isOwner)
BottomNavigationBarItem(
icon: const Icon(Icons.download_outlined),
label: 'download'.tr(),
tooltip: 'download'.tr(),
),
];
List<Function(int)> actionslist = [
(_) => shareAsset(),
(_) => handleArchive(asset()),
if (stack.isNotEmpty) (_) => showStackActionItems(),
(_) => handleDelete(asset()),
if (isOwner) (_) => handleArchive(asset()),
if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
if (isOwner) (_) => handleDelete(asset()),
if (!isOwner)
(_) => asset().isLocal
? null
: ref.watch(imageViewerStateProvider.notifier).downloadAsset(
asset(),
context,
),
];
return IgnorePointer(
@@ -755,7 +773,7 @@ class GalleryViewerPage extends HookConsumerWidget {
},
imageProvider: provider,
heroAttributes: PhotoViewHeroAttributes(
tag: isFromResponse
tag: isFromDto
? '${a.remoteId}-$heroOffset'
: a.id + heroOffset,
),
@@ -773,11 +791,6 @@ class GalleryViewerPage extends HookConsumerWidget {
localPosition = details.localPosition,
onDragUpdate: (_, details, __) =>
handleSwipeUpDown(details),
heroAttributes: PhotoViewHeroAttributes(
tag: isFromResponse
? '${a.remoteId}-$heroOffset'
: a.id + heroOffset,
),
filterQuality: FilterQuality.high,
maxScale: 1.0,
minScale: 1.0,

View File

@@ -9,6 +9,8 @@ import 'package:immich_mobile/modules/asset_viewer/ui/video_player_controls.dart
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:video_player/video_player.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@@ -125,99 +127,128 @@ class VideoPlayer extends StatefulWidget {
}
class _VideoPlayerState extends State<VideoPlayer> {
late VideoPlayerController videoPlayerController;
ChewieController? chewieController;
// late VideoPlayerController videoPlayerController;
// ChewieController? chewieController;
// Create a [Player] to control playback.
late final player = Player();
// Create a [VideoController] to handle video output from [Player].
late final controller = VideoController(player);
@override
void initState() {
super.initState();
initializePlayer();
// initializePlayer();
videoPlayerController.addListener(() {
if (videoPlayerController.value.isInitialized) {
if (videoPlayerController.value.isPlaying) {
WakelockPlus.enable();
widget.onPlaying?.call();
} else if (!videoPlayerController.value.isPlaying) {
WakelockPlus.disable();
widget.onPaused?.call();
}
// videoPlayerController.addListener(() {
// if (videoPlayerController.value.isInitialized) {
// if (videoPlayerController.value.isPlaying) {
// WakelockPlus.enable();
// widget.onPlaying?.call();
// } else if (!videoPlayerController.value.isPlaying) {
// WakelockPlus.disable();
// widget.onPaused?.call();
// }
if (videoPlayerController.value.position ==
videoPlayerController.value.duration) {
WakelockPlus.disable();
widget.onVideoEnded();
}
}
});
}
// if (videoPlayerController.value.position ==
// videoPlayerController.value.duration) {
// WakelockPlus.disable();
// widget.onVideoEnded();
// }
// }
// });
Future<void> initializePlayer() async {
try {
videoPlayerController = widget.file == null
? VideoPlayerController.networkUrl(
Uri.parse(widget.url!),
httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
)
: VideoPlayerController.file(widget.file!);
await videoPlayerController.initialize();
_createChewieController();
setState(() {});
} catch (e) {
debugPrint("ERROR initialize video player $e");
if (widget.file == null) {
player.open(
Media(
Uri.parse(widget.url!).toString(),
httpHeaders: {
"Authorization": "Bearer ${widget.jwtToken}",
},
),
);
} else {
player.open(
Media(
widget.file!.path,
),
);
}
}
_createChewieController() {
chewieController = ChewieController(
controlsSafeAreaMinimum: const EdgeInsets.only(
bottom: 100,
),
showOptions: true,
showControlsOnInitialize: false,
videoPlayerController: videoPlayerController,
autoPlay: true,
autoInitialize: true,
allowFullScreen: false,
allowedScreenSleep: false,
showControls: !widget.isMotionVideo,
customControls: const VideoPlayerControls(),
hideControlsTimer: const Duration(seconds: 5),
);
}
// Future<void> initializePlayer() async {
// try {
// videoPlayerController = widget.file == null
// ? VideoPlayerController.networkUrl(
// Uri.parse(widget.url!),
// httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
// )
// : VideoPlayerController.file(widget.file!);
// await videoPlayerController.initialize();
// _createChewieController();
// setState(() {});
// } catch (e) {
// debugPrint("ERROR initialize video player $e");
// }
// }
// _createChewieController() {
// chewieController = ChewieController(
// controlsSafeAreaMinimum: const EdgeInsets.only(
// bottom: 100,
// ),
// showOptions: true,
// showControlsOnInitialize: false,
// videoPlayerController: videoPlayerController,
// autoPlay: true,
// autoInitialize: true,
// allowFullScreen: false,
// allowedScreenSleep: false,
// showControls: !widget.isMotionVideo,
// customControls: const VideoPlayerControls(),
// hideControlsTimer: const Duration(seconds: 5),
// );
// }
@override
void dispose() {
super.dispose();
videoPlayerController.pause();
videoPlayerController.dispose();
chewieController?.dispose();
player.dispose();
// videoPlayerController.pause();
// videoPlayerController.dispose();
// chewieController?.dispose();
}
@override
Widget build(BuildContext context) {
if (chewieController?.videoPlayerController.value.isInitialized == true) {
return SizedBox(
child: Chewie(
controller: chewieController!,
),
);
} else {
return SizedBox(
height: MediaQuery.of(context).size.height,
width: MediaQuery.of(context).size.width,
child: Center(
child: Stack(
children: [
if (widget.placeholder != null) widget.placeholder!,
const Center(
child: ImmichLoadingIndicator(),
),
],
),
),
);
}
return SizedBox(
width: MediaQuery.of(context).size.width,
height: MediaQuery.of(context).size.height,
child: Video(controller: controller),
);
// if (chewieController?.videoPlayerController.value.isInitialized == true) {
// return SizedBox(
// child: Chewie(
// controller: chewieController!,
// ),
// );
// } else {
// return SizedBox(
// height: MediaQuery.of(context).size.height,
// width: MediaQuery.of(context).size.width,
// child: Center(
// child: Stack(
// children: [
// if (widget.placeholder != null) widget.placeholder!,
// const Center(
// child: ImmichLoadingIndicator(),
// ),
// ],
// ),
// ),
// );
// }
}
}

View File

@@ -174,46 +174,6 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
Widget buildStorageInformation() {
return ListTile(
leading: Icon(
Icons.storage_rounded,
color: Theme.of(context).primaryColor,
),
title: const Text(
"backup_controller_page_server_storage",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
isThreeLine: true,
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(
minHeight: 10.0,
value: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
color: Theme.of(context).primaryColor,
),
),
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: const Text('backup_controller_page_storage_format').tr(
args: [
backupState.serverInfo.diskUse,
backupState.serverInfo.diskSize,
],
),
),
],
),
),
);
}
ListTile buildAutoBackupController() {
final isAutoBackup = backupState.autoBackup;
final backUpOption = isAutoBackup
@@ -774,7 +734,6 @@ class BackupControllerPage extends HookConsumerWidget {
if (showBackupFix) const Divider(),
if (showBackupFix) buildCheckCorruptBackups(),
const Divider(),
buildStorageInformation(),
const Divider(),
const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),

View File

@@ -33,6 +33,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
final bool isOwner;
const ImmichAssetGrid({
super.key,
@@ -53,6 +54,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
});
@override
@@ -117,6 +119,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
showStack: showStack,
isOwner: isOwner,
),
);
}

View File

@@ -38,6 +38,7 @@ class ImmichAssetGridView extends StatefulWidget {
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
final bool isOwner;
const ImmichAssetGridView({
super.key,
@@ -58,6 +59,7 @@ class ImmichAssetGridView extends StatefulWidget {
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
this.isOwner = true,
});
@override
@@ -138,6 +140,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
showStorageIndicator: widget.showStorageIndicator,
heroOffset: widget.heroOffset,
showStack: widget.showStack,
isOwner: widget.isOwner,
);
}

View File

@@ -14,6 +14,7 @@ class ThumbnailImage extends StatelessWidget {
final int totalAssets;
final bool showStorageIndicator;
final bool showStack;
final bool isOwner;
final bool useGrayBoxPlaceholder;
final bool isSelected;
final bool multiselectEnabled;
@@ -29,6 +30,7 @@ class ThumbnailImage extends StatelessWidget {
required this.totalAssets,
this.showStorageIndicator = true,
this.showStack = false,
this.isOwner = true,
this.useGrayBoxPlaceholder = false,
this.isSelected = false,
this.multiselectEnabled = false,
@@ -43,7 +45,7 @@ class ThumbnailImage extends StatelessWidget {
final assetContainerColor =
isDarkTheme ? Colors.blueGrey : Theme.of(context).primaryColorLight;
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromResponse = asset.id == Isar.autoIncrement;
final isFromDto = asset.id == Isar.autoIncrement;
Widget buildSelectionIcon(Asset asset) {
if (isSelected) {
@@ -132,7 +134,7 @@ class ThumbnailImage extends StatelessWidget {
width: 300,
height: 300,
child: Hero(
tag: isFromResponse
tag: isFromDto
? '${asset.remoteId}-$heroOffset'
: asset.id + heroOffset,
child: ImmichImage(
@@ -181,6 +183,7 @@ class ThumbnailImage extends StatelessWidget {
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
isOwner: isOwner,
),
);
}

View File

@@ -1,171 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info/server_info.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class HomePageAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
const HomePageAppBar({
super.key,
this.onPopBack,
});
final Function? onPopBack;
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup;
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
AuthenticationState authState = ref.watch(authenticationProvider);
final user = Store.tryGet(StoreKey.currentUser);
buildProfilePhoto() {
if (authState.profileImagePath.isEmpty || user == null) {
return IconButton(
splashRadius: 25,
icon: const Icon(
Icons.face_outlined,
size: 30,
),
onPressed: () {
Scaffold.of(context).openDrawer();
},
);
} else {
return InkWell(
onTap: () {
Scaffold.of(context).openDrawer();
},
child: UserCircleAvatar(
radius: 18,
size: 33,
user: user,
),
);
}
}
return AppBar(
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(5),
),
),
leading: Builder(
builder: (BuildContext context) {
return Stack(
children: [
Center(
child: buildProfilePhoto(),
),
if (serverInfoState.isVersionMismatch)
Positioned(
bottom: 4,
right: 6,
child: GestureDetector(
onTap: () => Scaffold.of(context).openDrawer(),
child: Material(
// color: Colors.grey[200],
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
),
child: const Padding(
padding: EdgeInsets.all(2.0),
child: Icon(
Icons.info,
color: Color.fromARGB(255, 243, 188, 106),
size: 15,
),
),
),
),
),
],
);
},
),
title: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 22,
),
),
actions: [
Stack(
alignment: AlignmentDirectional.center,
children: [
if (backupState.backupProgress == BackUpProgressEnum.inProgress)
Positioned(
top: 10,
right: 12,
child: SizedBox(
height: 8,
width: 8,
child: CircularProgressIndicator(
strokeWidth: 1,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).primaryColor,
),
),
),
),
IconButton(
splashRadius: 25,
iconSize: 30,
icon: isEnableAutoBackup
? const Icon(
Icons.backup_rounded,
)
: Badge(
padding: const EdgeInsets.all(4),
backgroundColor: Colors.white,
label: const Icon(
Icons.cloud_off_rounded,
size: 8,
color: Colors.indigo,
),
child: Icon(
Icons.backup_rounded,
color: Theme.of(context).primaryColor,
),
),
onPressed: () async {
var onPop = await AutoRouter.of(context)
.push(const BackupControllerRoute());
if (onPop != null && onPop == true) {
onPopBack!();
}
},
),
if (backupState.backupProgress == BackUpProgressEnum.inProgress)
Positioned(
bottom: 5,
child: Text(
'${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}',
style:
const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
),
),
],
),
],
);
}
}

View File

@@ -1,144 +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/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer_header.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/server_info_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
class ProfileDrawer extends HookConsumerWidget {
const ProfileDrawer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashEnabled =
ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
buildSignOutButton() {
return ListTile(
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.logout_rounded,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_sign_out",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () async {
await ref.watch(authenticationProvider.notifier).logout();
ref.read(manualUploadProvider.notifier).cancelBackup();
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
},
);
}
buildSettingButton() {
return ListTile(
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.settings_rounded,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_settings",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () {
AutoRouter.of(context).push(const SettingsRoute());
},
);
}
buildAppLogButton() {
return ListTile(
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.assignment_outlined,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_app_logs",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () {
AutoRouter.of(context).push(const AppLogRoute());
},
);
}
buildTrashButton() {
return ListTile(
leading: SizedBox(
height: double.infinity,
child: Icon(
Icons.delete_rounded,
color: Theme.of(context).textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
"profile_drawer_trash",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: () {
AutoRouter.of(context).push(const TrashRoute());
},
);
}
return Drawer(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
children: [
const ProfileDrawerHeader(),
buildSettingButton(),
buildAppLogButton(),
if (trashEnabled) buildTrashButton(),
buildSignOutButton(),
],
),
const ServerInfoBox(),
],
),
);
}
}

View File

@@ -1,126 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/server_info/server_info.model.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:package_info_plus/package_info_plus.dart';
class ServerInfoBox extends HookConsumerWidget {
const ServerInfoBox({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
ServerInfo serverInfoState = ref.watch(serverInfoProvider);
final appInfo = useState({});
getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
"version": packageInfo.version,
"buildNumber": packageInfo.buildNumber,
};
}
useEffect(
() {
getPackageInfo();
return null;
},
[],
);
return Padding(
padding: const EdgeInsets.all(8.0),
child: Card(
elevation: 0,
color: Theme.of(context).scaffoldBackgroundColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Color.fromARGB(101, 201, 201, 201),
width: 1,
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
serverInfoState.isVersionMismatch
? serverInfoState.versionMismatchErrorMessage
: "profile_drawer_client_server_up_to_date".tr(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w600,
),
),
),
const Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"server_info_box_app_version".tr(),
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
const Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"server_info_box_server_version".tr(),
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
Text(
serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "?",
style: TextStyle(
fontSize: 11,
color: Colors.grey[500],
fontWeight: FontWeight.bold,
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -17,9 +17,7 @@ import 'package:immich_mobile/modules/home/models/selection_state.dart';
import 'package:immich_mobile/modules/home/providers/multiselect.provider.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/modules/home/ui/control_bottom_app_bar.dart';
import 'package:immich_mobile/modules/home/ui/home_page_app_bar.dart';
import 'package:immich_mobile/modules/memories/ui/memory_lane.dart';
import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
@@ -27,6 +25,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/immich_app_bar.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
@@ -74,10 +73,6 @@ class HomePage extends HookConsumerWidget {
[],
);
void reloadAllAsset() {
ref.watch(assetProvider.notifier).getAllAsset();
}
Widget buildBody() {
void selectionListener(
bool multiselect,
@@ -375,10 +370,7 @@ class HomePage extends HookConsumerWidget {
}
return Scaffold(
appBar: !selectionEnabledHook.value
? HomePageAppBar(onPopBack: reloadAllAsset)
: null,
drawer: const ProfileDrawer(),
appBar: !selectionEnabledHook.value ? const ImmichAppBar() : null,
body: buildBody(),
);
}

View File

@@ -16,7 +16,8 @@ class MemoryLane extends HookConsumerWidget {
final memoryLane = memoryLaneFutureProvider
.whenData(
(memories) => memories != null
? SizedBox(
? Container(
margin: const EdgeInsets.only(top: 10),
height: 200,
child: ListView.builder(
scrollDirection: Axis.horizontal,

View File

@@ -9,6 +9,7 @@ class SharedLink {
final bool allowUpload;
final String? thumbAssetId;
final String? description;
final String? password;
final DateTime? expiresAt;
final String key;
final bool showMetadata;
@@ -21,6 +22,7 @@ class SharedLink {
required this.allowUpload,
required this.thumbAssetId,
required this.description,
required this.password,
required this.expiresAt,
required this.key,
required this.showMetadata,
@@ -34,6 +36,7 @@ class SharedLink {
bool? allowDownload,
bool? allowUpload,
String? description,
String? password,
DateTime? expiresAt,
String? key,
bool? showMetadata,
@@ -46,6 +49,7 @@ class SharedLink {
allowDownload: allowDownload ?? this.allowDownload,
allowUpload: allowUpload ?? this.allowUpload,
description: description ?? this.description,
password: password ?? this.password,
expiresAt: expiresAt ?? this.expiresAt,
key: key ?? this.key,
showMetadata: showMetadata ?? this.showMetadata,
@@ -58,6 +62,7 @@ class SharedLink {
allowDownload = dto.allowDownload,
allowUpload = dto.allowUpload,
description = dto.description,
password = dto.password,
expiresAt = dto.expiresAt,
key = dto.key,
showMetadata = dto.showMetadata,
@@ -75,7 +80,7 @@ class SharedLink {
@override
String toString() =>
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
'SharedLink(id=$id, title=$title, thumbAssetId=$thumbAssetId, allowDownload=$allowDownload, allowUpload=$allowUpload, description=$description, password=$password, expiresAt=$expiresAt, key=$key, showMetadata=$showMetadata, type=$type)';
@override
bool operator ==(Object other) =>
@@ -87,6 +92,7 @@ class SharedLink {
other.allowDownload == allowDownload &&
other.allowUpload == allowUpload &&
other.description == description &&
other.password == password &&
other.expiresAt == expiresAt &&
other.key == key &&
other.showMetadata == showMetadata &&
@@ -100,6 +106,7 @@ class SharedLink {
allowDownload.hashCode ^
allowUpload.hashCode ^
description.hashCode ^
password.hashCode ^
expiresAt.hashCode ^
key.hashCode ^
showMetadata.hashCode ^

View File

@@ -40,6 +40,7 @@ class SharedLinkService {
required bool allowDownload,
required bool allowUpload,
String? description,
String? password,
String? albumId,
List<String>? assetIds,
DateTime? expiresAt,
@@ -57,6 +58,7 @@ class SharedLinkService {
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
);
} else if (assetIds != null) {
dto = SharedLinkCreateDto(
@@ -66,6 +68,7 @@ class SharedLinkService {
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
assetIds: assetIds,
);
}
@@ -90,6 +93,7 @@ class SharedLinkService {
required bool? allowUpload,
bool? changeExpiry = false,
String? description,
String? password,
DateTime? expiresAt,
}) async {
try {
@@ -101,6 +105,7 @@ class SharedLinkService {
allowUpload: allowUpload,
expiresAt: expiresAt,
description: description,
password: password,
changeExpiryTime: changeExpiry,
),
);

View File

@@ -30,6 +30,8 @@ class SharedLinkEditPage extends HookConsumerWidget {
final descriptionController =
useTextEditingController(text: existingLink?.description ?? "");
final descriptionFocusNode = useFocusNode();
final passwordController =
useTextEditingController(text: existingLink?.password ?? "");
final showMetadata = useState(existingLink?.showMetadata ?? true);
final allowDownload = useState(existingLink?.allowDownload ?? true);
final allowUpload = useState(existingLink?.allowUpload ?? false);
@@ -113,6 +115,31 @@ class SharedLinkEditPage extends HookConsumerWidget {
);
}
Widget buildPasswordField() {
return TextField(
controller: passwordController,
enabled: newShareLink.value.isEmpty,
autofocus: false,
decoration: InputDecoration(
labelText: 'shared_link_edit_password'.tr(),
labelStyle: TextStyle(
fontWeight: FontWeight.bold,
color: themeData.primaryColor,
),
floatingLabelBehavior: FloatingLabelBehavior.always,
border: const OutlineInputBorder(),
hintText: 'shared_link_edit_password_hint'.tr(),
hintStyle: const TextStyle(
fontWeight: FontWeight.normal,
fontSize: 14,
),
disabledBorder: OutlineInputBorder(
borderSide: BorderSide(color: Colors.grey.withOpacity(0.5)),
),
),
);
}
Widget buildShowMetaButton() {
return SwitchListTile.adaptive(
value: showMetadata.value,
@@ -229,7 +256,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
void copyLinkToClipboard() {
Clipboard.setData(
ClipboardData(
text: newShareLink.value,
text: passwordController.text.isEmpty
? newShareLink.value
: "Link: ${newShareLink.value}\nPassword: ${passwordController.text}",
),
).then((_) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -302,6 +331,9 @@ class SharedLinkEditPage extends HookConsumerWidget {
description: descriptionController.text.isEmpty
? null
: descriptionController.text,
password: passwordController.text.isEmpty
? null
: passwordController.text,
expiresAt: expiryAfter.value == 0 ? null : calculateExpiry(),
);
ref.invalidate(sharedLinksStateProvider);
@@ -324,6 +356,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
bool? upload;
bool? meta;
String? desc;
String? password;
DateTime? expiry;
bool? changeExpiry;
@@ -343,6 +376,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
desc = descriptionController.text;
}
if (passwordController.text != existingLink!.password) {
password = passwordController.text;
}
if (editExpiry.value) {
expiry = expiryAfter.value == 0 ? null : calculateExpiry();
changeExpiry = true;
@@ -354,6 +391,7 @@ class SharedLinkEditPage extends HookConsumerWidget {
allowDownload: download,
allowUpload: upload,
description: desc,
password: password,
expiresAt: expiry,
changeExpiry: changeExpiry,
);
@@ -385,6 +423,10 @@ class SharedLinkEditPage extends HookConsumerWidget {
padding: const EdgeInsets.all(padding),
child: buildDescriptionField(),
),
Padding(
padding: const EdgeInsets.all(padding),
child: buildPasswordField(),
),
Padding(
padding: const EdgeInsets.only(
left: padding,

View File

@@ -133,10 +133,7 @@ part 'router.gr.dart';
DuplicateGuard,
],
),
CustomRoute(
page: AppLogPage,
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(page: AppLogPage, guards: [DuplicateGuard]),
AutoRoute(
page: AppLogDetailPage,
),

View File

@@ -72,6 +72,7 @@ class _$AppRouter extends RootStackRouter {
totalAssets: args.totalAssets,
heroOffset: args.heroOffset,
showStack: args.showStack,
isOwner: args.isOwner,
),
);
},
@@ -230,12 +231,9 @@ class _$AppRouter extends RootStackRouter {
);
},
AppLogRoute.name: (routeData) {
return CustomPage<dynamic>(
return MaterialPageX<dynamic>(
routeData: routeData,
child: const AppLogPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false,
);
},
AppLogDetailRoute.name: (routeData) {
@@ -582,6 +580,7 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(
AppLogRoute.name,
path: '/app-log-page',
guards: [duplicateGuard],
),
RouteConfig(
AppLogDetailRoute.name,
@@ -749,6 +748,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
required int totalAssets,
int heroOffset = 0,
bool showStack = false,
bool isOwner = true,
}) : super(
GalleryViewerRoute.name,
path: '/gallery-viewer-page',
@@ -759,6 +759,7 @@ class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
totalAssets: totalAssets,
heroOffset: heroOffset,
showStack: showStack,
isOwner: isOwner,
),
);
@@ -773,6 +774,7 @@ class GalleryViewerRouteArgs {
required this.totalAssets,
this.heroOffset = 0,
this.showStack = false,
this.isOwner = true,
});
final Key? key;
@@ -787,9 +789,11 @@ class GalleryViewerRouteArgs {
final bool showStack;
final bool isOwner;
@override
String toString() {
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack}';
return 'GalleryViewerRouteArgs{key: $key, initialIndex: $initialIndex, loadAsset: $loadAsset, totalAssets: $totalAssets, heroOffset: $heroOffset, showStack: $showStack, isOwner: $isOwner}';
}
}

View File

@@ -1,6 +1,5 @@
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart';
part 'exif_info.g.dart';
@@ -165,7 +164,11 @@ double? _exposureTimeToSeconds(String? s) {
}
final parts = s.split("/");
if (parts.length == 2) {
return parts[0].toDouble() / parts[1].toDouble();
final numerator = double.tryParse(parts[0]);
final denominator = double.tryParse(parts[1]);
if (numerator != null && denominator != null) {
return numerator / denominator;
}
}
return null;
}

View File

@@ -0,0 +1,263 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/manual_upload.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_profile_info.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_server_info.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:url_launcher/url_launcher.dart';
class ImmichAppBarDialog extends HookConsumerWidget {
const ImmichAppBarDialog({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final theme = Theme.of(context);
bool isDarkTheme = theme.brightness == Brightness.dark;
bool isHorizontal = MediaQuery.of(context).size.width > 600;
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
final user = ref.watch(currentUserProvider);
useEffect(
() {
ref.read(backupProvider.notifier).updateServerInfo();
return null;
},
[user],
);
buildTopRow() {
return Row(
children: [
InkWell(
onTap: () => Navigator.of(context).pop(),
child: const Icon(
Icons.close,
size: 20,
),
),
Expanded(
child: Align(
alignment: Alignment.center,
child: Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
color: Theme.of(context).primaryColor,
fontSize: 15,
),
),
),
),
],
);
}
buildActionButton(IconData icon, String text, Function() onTap) {
return ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30),
minLeadingWidth: 40,
leading: SizedBox(
child: Icon(
icon,
color: theme.textTheme.labelMedium?.color,
size: 20,
),
),
title: Text(
text,
style:
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
).tr(),
onTap: onTap,
);
}
buildSettingButton() {
return buildActionButton(
Icons.settings_rounded,
"profile_drawer_settings",
() => AutoRouter.of(context).push(const SettingsRoute()),
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
"profile_drawer_app_logs",
() => AutoRouter.of(context).push(const AppLogRoute()),
);
}
buildSignOutButton() {
return buildActionButton(
Icons.logout_rounded,
"profile_drawer_sign_out",
() async {
showDialog(
context: context,
builder: (BuildContext ctx) {
return ConfirmDialog(
title: "app_bar_signout_dialog_title",
content: "app_bar_signout_dialog_content",
ok: "app_bar_signout_dialog_ok",
onOk: () async {
await ref.watch(authenticationProvider.notifier).logout();
ref.read(manualUploadProvider.notifier).cancelBackup();
ref.watch(backupProvider.notifier).cancelBackup();
ref.watch(assetProvider.notifier).clearAllAsset();
ref.watch(websocketProvider.notifier).disconnect();
AutoRouter.of(context).replace(const LoginRoute());
},
);
},
);
},
);
}
Widget buildStorageInformation() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 3),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: isDarkTheme
? Theme.of(context).scaffoldBackgroundColor
: const Color.fromARGB(255, 225, 229, 240),
),
child: ListTile(
minLeadingWidth: 50,
leading: Icon(
Icons.storage_rounded,
color: theme.primaryColor,
),
title: const Text(
"backup_controller_page_server_storage",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
isThreeLine: true,
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(
minHeight: 5.0,
value: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
color: theme.primaryColor,
),
),
Padding(
padding: const EdgeInsets.only(top: 12.0),
child:
const Text('backup_controller_page_storage_format').tr(
args: [
backupState.serverInfo.diskUse,
backupState.serverInfo.diskSize,
],
),
),
],
),
),
),
),
);
}
buildFooter() {
return Padding(
padding: const EdgeInsets.only(top: 10, bottom: 20),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
InkWell(
onTap: () {
Navigator.of(context).pop();
launchUrl(
Uri.parse('https://immich.app'),
);
},
child: Text(
"profile_drawer_documentation",
style: Theme.of(context).textTheme.bodySmall,
).tr(),
),
const SizedBox(
width: 20,
child: Text(
"",
textAlign: TextAlign.center,
),
),
InkWell(
onTap: () {
Navigator.of(context).pop();
launchUrl(
Uri.parse('https://github.com/immich-app/immich'),
);
},
child: Text(
"profile_drawer_github",
style: Theme.of(context).textTheme.bodySmall,
).tr(),
),
],
),
);
}
return Dialog(
clipBehavior: Clip.hardEdge,
alignment: Alignment.topCenter,
insetPadding: EdgeInsets.only(
top: isHorizontal ? 20 : 60,
left: horizontalPadding,
right: horizontalPadding,
bottom: isHorizontal ? 20 : 100,
),
backgroundColor: theme.cardColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: SizedBox(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
child: buildTopRow(),
),
const AppBarProfileInfoBox(),
buildStorageInformation(),
const AppBarServerInfo(),
buildAppLogButton(),
buildSettingButton(),
buildSignOutButton(),
buildFooter(),
],
),
),
),
);
}
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/modules/home/providers/upload_profile_image.provider.dart';
@@ -9,8 +8,8 @@ import 'package:immich_mobile/modules/login/models/authentication_state.model.da
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class ProfileDrawerHeader extends HookConsumerWidget {
const ProfileDrawerHeader({
class AppBarProfileInfoBox extends HookConsumerWidget {
const AppBarProfileInfoBox({
Key? key,
}) : super(key: key);
@@ -23,30 +22,24 @@ class ProfileDrawerHeader extends HookConsumerWidget {
final user = Store.tryGet(StoreKey.currentUser);
buildUserProfileImage() {
const immichImage = CircleAvatar(
radius: 20,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
if (authState.profileImagePath.isEmpty || user == null) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
return immichImage;
}
var userImage = UserCircleAvatar(
radius: 35,
size: 66,
final userImage = UserCircleAvatar(
radius: 20,
size: 40,
user: user,
);
if (uploadProfileImageStatus == UploadProfileStatus.idle) {
if (authState.profileImagePath.isNotEmpty) {
return userImage;
} else {
return const CircleAvatar(
radius: 33,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
}
return authState.profileImagePath.isNotEmpty ? userImage : immichImage;
}
if (uploadProfileImageStatus == UploadProfileStatus.success) {
@@ -54,18 +47,18 @@ class ProfileDrawerHeader extends HookConsumerWidget {
}
if (uploadProfileImageStatus == UploadProfileStatus.failure) {
return const CircleAvatar(
radius: 35,
backgroundImage: AssetImage('assets/immich-logo-no-outline.png'),
backgroundColor: Colors.transparent,
);
return immichImage;
}
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
return const ImmichLoadingIndicator();
return const SizedBox(
height: 40,
width: 40,
child: ImmichLoadingIndicator(borderRadius: 20),
);
}
return const SizedBox();
return immichImage;
}
pickUserProfileImage() async {
@@ -80,54 +73,45 @@ class ProfileDrawerHeader extends HookConsumerWidget {
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
if (success) {
final profileImagePath =
ref.read(uploadProfileImageProvider).profileImagePath;
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
ref.read(uploadProfileImageProvider).profileImagePath,
profileImagePath,
);
if (user != null) {
user.profileImagePath = profileImagePath;
Store.put(StoreKey.currentUser, user);
}
}
}
}
useEffect(
() {
// buildUserProfileImage();
return null;
},
[],
);
return DrawerHeader(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDarkMode
? [
const Color.fromARGB(255, 22, 25, 48),
const Color.fromARGB(255, 13, 13, 13),
const Color.fromARGB(255, 0, 0, 0),
]
: [
const Color.fromARGB(255, 216, 219, 238),
const Color.fromARGB(255, 242, 242, 242),
Colors.white,
],
begin: Alignment.centerRight,
end: Alignment.centerLeft,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 10.0),
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).scaffoldBackgroundColor
: const Color.fromARGB(255, 225, 229, 240),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(10),
topRight: Radius.circular(10),
),
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
child: ListTile(
minLeadingWidth: 50,
leading: GestureDetector(
onTap: pickUserProfileImage,
child: Stack(
clipBehavior: Clip.none,
children: [
buildUserProfileImage(),
Positioned(
bottom: 0,
right: -5,
bottom: -5,
right: -8,
child: Material(
color: isDarkMode ? Colors.grey[700] : Colors.grey[100],
color: isDarkMode ? Colors.blueGrey[800] : Colors.white,
elevation: 3,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50.0),
@@ -135,7 +119,7 @@ class ProfileDrawerHeader extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.all(5.0),
child: Icon(
Icons.edit,
Icons.camera_alt_outlined,
color: Theme.of(context).primaryColor,
size: 14,
),
@@ -145,19 +129,21 @@ class ProfileDrawerHeader extends HookConsumerWidget {
],
),
),
Text(
title: Text(
"${authState.firstName} ${authState.lastName}",
style: TextStyle(
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.bold,
fontSize: 24,
fontSize: 16,
),
),
Text(
subtitle: Text(
authState.userEmail,
style: Theme.of(context).textTheme.labelMedium,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontSize: 12,
),
),
],
),
),
);
}

View File

@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/server_info/server_info.model.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:package_info_plus/package_info_plus.dart';
class AppBarServerInfo extends HookConsumerWidget {
const AppBarServerInfo({
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
ServerInfo serverInfoState = ref.watch(serverInfoProvider);
final appInfo = useState({});
getPackageInfo() async {
PackageInfo packageInfo = await PackageInfo.fromPlatform();
appInfo.value = {
"version": packageInfo.version,
"buildNumber": packageInfo.buildNumber,
};
}
useEffect(
() {
getPackageInfo();
return null;
},
[],
);
return Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 10.0),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).brightness == Brightness.dark
? Theme.of(context).scaffoldBackgroundColor
: const Color.fromARGB(255, 225, 229, 240),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(10),
bottomRight: Radius.circular(10),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
serverInfoState.isVersionMismatch
? serverInfoState.versionMismatchErrorMessage
: "profile_drawer_client_server_up_to_date".tr(),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).primaryColor,
fontWeight: FontWeight.w600,
),
),
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_app_version".tr(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.labelSmall?.color,
fontWeight: FontWeight.bold,
),
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
style: TextStyle(
fontSize: 11,
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_server_version".tr(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.labelSmall?.color,
fontWeight: FontWeight.bold,
),
),
),
),
Expanded(
flex: 0,
child: Padding(
padding: const EdgeInsets.only(right: 10.0),
child: Text(
serverInfoState.serverVersion.major > 0
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
: "?",
style: TextStyle(
fontSize: 11,
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
),
),
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 10),
child: Divider(
color: Color.fromARGB(101, 201, 201, 201),
thickness: 1,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 10.0),
child: Text(
"server_info_box_server_url".tr(),
style: TextStyle(
fontSize: 11,
color: Theme.of(context).textTheme.labelSmall?.color,
fontWeight: FontWeight.bold,
),
),
),
),
Expanded(
flex: 0,
child: Container(
width: 200,
padding: const EdgeInsets.only(right: 10.0),
child: Text(
getServerUrl() ?? '--',
style: TextStyle(
fontSize: 11,
color: Theme.of(context)
.textTheme
.labelSmall
?.color
?.withOpacity(0.5),
fontWeight: FontWeight.bold,
overflow: TextOverflow.ellipsis,
),
textAlign: TextAlign.end,
),
),
),
],
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,192 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/ui/app_bar_dialog/app_bar_dialog.dart';
import 'package:immich_mobile/shared/ui/user_circle_avatar.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info/server_info.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
final Widget? action;
const ImmichAppBar({super.key, this.action});
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
final bool isEnableAutoBackup =
backupState.backgroundBackup || backupState.autoBackup;
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
AuthenticationState authState = ref.watch(authenticationProvider);
final user = Store.tryGet(StoreKey.currentUser);
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
const widgetSize = 30.0;
buildProfilePhoto() {
return InkWell(
onTap: () => showDialog(
context: context,
useRootNavigator: false,
builder: (ctx) => const ImmichAppBarDialog(),
),
borderRadius: BorderRadius.circular(12),
child: authState.profileImagePath.isEmpty || user == null
? const Icon(
Icons.face_outlined,
size: widgetSize,
)
: UserCircleAvatar(
radius: 15,
size: 27,
user: user,
),
);
}
buildProfileIndicator() {
return Badge(
label: Container(
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: const Icon(
Icons.info,
color: Color.fromARGB(255, 243, 188, 106),
size: widgetSize / 2,
),
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: serverInfoState.isVersionMismatch,
offset: const Offset(2, 2),
child: buildProfilePhoto(),
);
}
getBackupBadgeIcon() {
final iconColor = isDarkMode ? Colors.white : Colors.black;
if (isEnableAutoBackup) {
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
return Container(
padding: const EdgeInsets.all(3.5),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
),
);
} else if (backupState.backupProgress !=
BackUpProgressEnum.inBackground &&
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
return Icon(
Icons.check_outlined,
size: 9,
color: iconColor,
);
}
}
if (!isEnableAutoBackup) {
return Icon(
Icons.cloud_off_rounded,
size: 9,
color: iconColor,
);
}
}
buildBackupIndicator() {
final indicatorIcon = getBackupBadgeIcon();
final badgeBackground = isDarkMode ? Colors.blueGrey[800] : Colors.white;
return InkWell(
onTap: () => AutoRouter.of(context).push(const BackupControllerRoute()),
borderRadius: BorderRadius.circular(12),
child: Badge(
label: Container(
width: widgetSize / 2,
height: widgetSize / 2,
decoration: BoxDecoration(
color: badgeBackground,
border: Border.all(
color: isDarkMode ? Colors.black : Colors.grey,
),
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: indicatorIcon,
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: indicatorIcon != null,
offset: const Offset(2, 2),
child: Icon(
Icons.backup_rounded,
size: widgetSize,
color: Theme.of(context).primaryColor,
),
),
);
}
return AppBar(
backgroundColor: Theme.of(context).appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(5),
),
),
automaticallyImplyLeading: false,
centerTitle: false,
title: Builder(
builder: (BuildContext context) {
return Row(
children: [
Container(
padding: const EdgeInsets.only(top: 3),
width: 28,
height: 28,
child: Image.asset(
'assets/immich-logo.png',
),
),
Container(
margin: const EdgeInsets.only(left: 10),
child: const Text(
'IMMICH',
style: TextStyle(
fontFamily: 'SnowburstOne',
fontWeight: FontWeight.bold,
fontSize: 24,
),
),
),
],
);
},
),
actions: [
if (action != null)
Padding(padding: const EdgeInsets.only(right: 20), child: action!),
Padding(
padding: const EdgeInsets.only(right: 20),
child: buildBackupIndicator(),
),
Padding(
padding: const EdgeInsets.only(right: 20),
child: buildProfileIndicator(),
),
],
);
}
}

View File

@@ -1,8 +1,11 @@
import 'package:flutter/material.dart';
class ImmichLoadingIndicator extends StatelessWidget {
final double? borderRadius;
const ImmichLoadingIndicator({
Key? key,
this.borderRadius,
}) : super(key: key);
@override
@@ -12,7 +15,7 @@ class ImmichLoadingIndicator extends StatelessWidget {
width: 60,
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withAlpha(200),
borderRadius: BorderRadius.circular(10),
borderRadius: BorderRadius.circular(borderRadius ?? 10),
),
padding: const EdgeInsets.all(15),
child: const CircularProgressIndicator(

View File

@@ -1,5 +1,6 @@
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/store.dart';
@@ -46,7 +47,7 @@ class UserCircleAvatar extends ConsumerWidget {
radius: radius,
child: user.profileImagePath == ""
? Text(
user.firstName[0],
user.firstName[0].toUpperCase(),
style: const TextStyle(
fontWeight: FontWeight.bold,
color: Colors.black,
@@ -54,19 +55,18 @@ class UserCircleAvatar extends ConsumerWidget {
)
: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: FadeInImage(
child: CachedNetworkImage(
fit: BoxFit.cover,
placeholder: MemoryImage(kTransparentImage),
cacheKey: user.profileImagePath,
width: size,
height: size,
image: NetworkImage(
profileImageUrl,
headers: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
},
),
fadeInDuration: const Duration(milliseconds: 200),
imageErrorBuilder: (context, error, stackTrace) =>
placeholder: (_, __) => Image.memory(kTransparentImage),
imageUrl: profileImageUrl,
httpHeaders: {
"Authorization": "Bearer ${Store.get(StoreKey.accessToken)}",
},
fadeInDuration: const Duration(milliseconds: 300),
errorWidget: (context, error, stackTrace) =>
Image.memory(kTransparentImage),
),
),

View File

@@ -1,6 +1,7 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
String getThumbnailUrl(
@@ -35,8 +36,10 @@ String getAlbumThumbnailUrl(
if (album.thumbnail.value?.remoteId == null) {
return '';
}
return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!,
type: type,);
return getThumbnailUrlForRemoteId(
album.thumbnail.value!.remoteId!,
type: type,
);
}
String getAlbumThumbNailCacheKey(
@@ -57,7 +60,9 @@ String getImageUrl(final Asset asset) {
}
String getImageCacheKey(final Asset asset) {
return '${asset.id}_fullStage';
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isFromDto = asset.id == Isar.autoIncrement;
return '${isFromDto ? asset.remoteId : asset.id}_fullStage';
}
String getThumbnailUrlForRemoteId(

View File

@@ -8,6 +8,10 @@ doc/APIKeyCreateDto.md
doc/APIKeyCreateResponseDto.md
doc/APIKeyResponseDto.md
doc/APIKeyUpdateDto.md
doc/ActivityApi.md
doc/ActivityCreateDto.md
doc/ActivityResponseDto.md
doc/ActivityStatisticsResponseDto.md
doc/AddUsersDto.md
doc/AdminSignupResponseDto.md
doc/AlbumApi.md
@@ -97,6 +101,7 @@ doc/PersonResponseDto.md
doc/PersonStatisticsResponseDto.md
doc/PersonUpdateDto.md
doc/QueueStatusDto.md
doc/ReactionType.md
doc/RecognitionConfig.md
doc/ScanLibraryDto.md
doc/SearchAlbumResponseDto.md
@@ -128,6 +133,8 @@ doc/SystemConfigApi.md
doc/SystemConfigDto.md
doc/SystemConfigFFmpegDto.md
doc/SystemConfigJobDto.md
doc/SystemConfigLibraryDto.md
doc/SystemConfigLibraryScanDto.md
doc/SystemConfigMachineLearningDto.md
doc/SystemConfigMapDto.md
doc/SystemConfigNewVersionCheckDto.md
@@ -156,12 +163,13 @@ doc/UpdateTagDto.md
doc/UpdateUserDto.md
doc/UsageByUserDto.md
doc/UserApi.md
doc/UserCountResponseDto.md
doc/UserDto.md
doc/UserResponseDto.md
doc/ValidateAccessTokenResponseDto.md
doc/VideoCodec.md
git_push.sh
lib/api.dart
lib/api/activity_api.dart
lib/api/album_api.dart
lib/api/api_key_api.dart
lib/api/asset_api.dart
@@ -186,6 +194,9 @@ lib/auth/authentication.dart
lib/auth/http_basic_auth.dart
lib/auth/http_bearer_auth.dart
lib/auth/oauth.dart
lib/model/activity_create_dto.dart
lib/model/activity_response_dto.dart
lib/model/activity_statistics_response_dto.dart
lib/model/add_users_dto.dart
lib/model/admin_signup_response_dto.dart
lib/model/album_count_response_dto.dart
@@ -270,6 +281,7 @@ lib/model/person_response_dto.dart
lib/model/person_statistics_response_dto.dart
lib/model/person_update_dto.dart
lib/model/queue_status_dto.dart
lib/model/reaction_type.dart
lib/model/recognition_config.dart
lib/model/scan_library_dto.dart
lib/model/search_album_response_dto.dart
@@ -297,6 +309,8 @@ lib/model/smart_info_response_dto.dart
lib/model/system_config_dto.dart
lib/model/system_config_f_fmpeg_dto.dart
lib/model/system_config_job_dto.dart
lib/model/system_config_library_dto.dart
lib/model/system_config_library_scan_dto.dart
lib/model/system_config_machine_learning_dto.dart
lib/model/system_config_map_dto.dart
lib/model/system_config_new_version_check_dto.dart
@@ -323,11 +337,15 @@ lib/model/update_stack_parent_dto.dart
lib/model/update_tag_dto.dart
lib/model/update_user_dto.dart
lib/model/usage_by_user_dto.dart
lib/model/user_count_response_dto.dart
lib/model/user_dto.dart
lib/model/user_response_dto.dart
lib/model/validate_access_token_response_dto.dart
lib/model/video_codec.dart
pubspec.yaml
test/activity_api_test.dart
test/activity_create_dto_test.dart
test/activity_response_dto_test.dart
test/activity_statistics_response_dto_test.dart
test/add_users_dto_test.dart
test/admin_signup_response_dto_test.dart
test/album_api_test.dart
@@ -422,6 +440,7 @@ test/person_response_dto_test.dart
test/person_statistics_response_dto_test.dart
test/person_update_dto_test.dart
test/queue_status_dto_test.dart
test/reaction_type_test.dart
test/recognition_config_test.dart
test/scan_library_dto_test.dart
test/search_album_response_dto_test.dart
@@ -453,6 +472,8 @@ test/system_config_api_test.dart
test/system_config_dto_test.dart
test/system_config_f_fmpeg_dto_test.dart
test/system_config_job_dto_test.dart
test/system_config_library_dto_test.dart
test/system_config_library_scan_dto_test.dart
test/system_config_machine_learning_dto_test.dart
test/system_config_map_dto_test.dart
test/system_config_new_version_check_dto_test.dart
@@ -481,7 +502,7 @@ test/update_tag_dto_test.dart
test/update_user_dto_test.dart
test/usage_by_user_dto_test.dart
test/user_api_test.dart
test/user_count_response_dto_test.dart
test/user_dto_test.dart
test/user_response_dto_test.dart
test/validate_access_token_response_dto_test.dart
test/video_codec_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.83.0
- API version: 1.84.0
- Build package: org.openapitools.codegen.languages.DartClientCodegen
## Requirements
@@ -77,6 +77,10 @@ Class | Method | HTTP request | Description
*APIKeyApi* | [**getKey**](doc//APIKeyApi.md#getkey) | **GET** /api-key/{id} |
*APIKeyApi* | [**getKeys**](doc//APIKeyApi.md#getkeys) | **GET** /api-key |
*APIKeyApi* | [**updateKey**](doc//APIKeyApi.md#updatekey) | **PUT** /api-key/{id} |
*ActivityApi* | [**createActivity**](doc//ActivityApi.md#createactivity) | **POST** /activity |
*ActivityApi* | [**deleteActivity**](doc//ActivityApi.md#deleteactivity) | **DELETE** /activity/{id} |
*ActivityApi* | [**getActivities**](doc//ActivityApi.md#getactivities) | **GET** /activity |
*ActivityApi* | [**getActivityStatistics**](doc//ActivityApi.md#getactivitystatistics) | **GET** /activity/statistics |
*AlbumApi* | [**addAssetsToAlbum**](doc//AlbumApi.md#addassetstoalbum) | **PUT** /album/{id}/assets |
*AlbumApi* | [**addUsersToAlbum**](doc//AlbumApi.md#adduserstoalbum) | **PUT** /album/{id}/users |
*AlbumApi* | [**createAlbum**](doc//AlbumApi.md#createalbum) | **POST** /album |
@@ -194,7 +198,6 @@ Class | Method | HTTP request | Description
*UserApi* | [**getMyUserInfo**](doc//UserApi.md#getmyuserinfo) | **GET** /user/me |
*UserApi* | [**getProfileImage**](doc//UserApi.md#getprofileimage) | **GET** /user/profile-image/{id} |
*UserApi* | [**getUserById**](doc//UserApi.md#getuserbyid) | **GET** /user/info/{id} |
*UserApi* | [**getUserCount**](doc//UserApi.md#getusercount) | **GET** /user/count |
*UserApi* | [**restoreUser**](doc//UserApi.md#restoreuser) | **POST** /user/{id}/restore |
*UserApi* | [**updateUser**](doc//UserApi.md#updateuser) | **PUT** /user |
@@ -205,6 +208,9 @@ Class | Method | HTTP request | Description
- [APIKeyCreateResponseDto](doc//APIKeyCreateResponseDto.md)
- [APIKeyResponseDto](doc//APIKeyResponseDto.md)
- [APIKeyUpdateDto](doc//APIKeyUpdateDto.md)
- [ActivityCreateDto](doc//ActivityCreateDto.md)
- [ActivityResponseDto](doc//ActivityResponseDto.md)
- [ActivityStatisticsResponseDto](doc//ActivityStatisticsResponseDto.md)
- [AddUsersDto](doc//AddUsersDto.md)
- [AdminSignupResponseDto](doc//AdminSignupResponseDto.md)
- [AlbumCountResponseDto](doc//AlbumCountResponseDto.md)
@@ -285,6 +291,7 @@ Class | Method | HTTP request | Description
- [PersonStatisticsResponseDto](doc//PersonStatisticsResponseDto.md)
- [PersonUpdateDto](doc//PersonUpdateDto.md)
- [QueueStatusDto](doc//QueueStatusDto.md)
- [ReactionType](doc//ReactionType.md)
- [RecognitionConfig](doc//RecognitionConfig.md)
- [ScanLibraryDto](doc//ScanLibraryDto.md)
- [SearchAlbumResponseDto](doc//SearchAlbumResponseDto.md)
@@ -312,6 +319,8 @@ Class | Method | HTTP request | Description
- [SystemConfigDto](doc//SystemConfigDto.md)
- [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md)
- [SystemConfigJobDto](doc//SystemConfigJobDto.md)
- [SystemConfigLibraryDto](doc//SystemConfigLibraryDto.md)
- [SystemConfigLibraryScanDto](doc//SystemConfigLibraryScanDto.md)
- [SystemConfigMachineLearningDto](doc//SystemConfigMachineLearningDto.md)
- [SystemConfigMapDto](doc//SystemConfigMapDto.md)
- [SystemConfigNewVersionCheckDto](doc//SystemConfigNewVersionCheckDto.md)
@@ -338,7 +347,7 @@ Class | Method | HTTP request | Description
- [UpdateTagDto](doc//UpdateTagDto.md)
- [UpdateUserDto](doc//UpdateUserDto.md)
- [UsageByUserDto](doc//UsageByUserDto.md)
- [UserCountResponseDto](doc//UserCountResponseDto.md)
- [UserDto](doc//UserDto.md)
- [UserResponseDto](doc//UserResponseDto.md)
- [ValidateAccessTokenResponseDto](doc//ValidateAccessTokenResponseDto.md)
- [VideoCodec](doc//VideoCodec.md)

244
mobile/openapi/doc/ActivityApi.md generated Normal file
View File

@@ -0,0 +1,244 @@
# openapi.api.ActivityApi
## Load the API package
```dart
import 'package:openapi/api.dart';
```
All URIs are relative to */api*
Method | HTTP request | Description
------------- | ------------- | -------------
[**createActivity**](ActivityApi.md#createactivity) | **POST** /activity |
[**deleteActivity**](ActivityApi.md#deleteactivity) | **DELETE** /activity/{id} |
[**getActivities**](ActivityApi.md#getactivities) | **GET** /activity |
[**getActivityStatistics**](ActivityApi.md#getactivitystatistics) | **GET** /activity/statistics |
# **createActivity**
> ActivityResponseDto createActivity(activityCreateDto)
### 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 = ActivityApi();
final activityCreateDto = ActivityCreateDto(); // ActivityCreateDto |
try {
final result = api_instance.createActivity(activityCreateDto);
print(result);
} catch (e) {
print('Exception when calling ActivityApi->createActivity: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**activityCreateDto** | [**ActivityCreateDto**](ActivityCreateDto.md)| |
### Return type
[**ActivityResponseDto**](ActivityResponseDto.md)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: application/json
- **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)
# **deleteActivity**
> deleteActivity(id)
### 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 = ActivityApi();
final id = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
api_instance.deleteActivity(id);
} catch (e) {
print('Exception when calling ActivityApi->deleteActivity: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**id** | **String**| |
### Return type
void (empty response body)
### Authorization
[cookie](../README.md#cookie), [api_key](../README.md#api_key), [bearer](../README.md#bearer)
### HTTP request headers
- **Content-Type**: Not defined
- **Accept**: Not defined
[[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)
# **getActivities**
> List<ActivityResponseDto> getActivities(albumId, assetId, type, userId)
### 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 = ActivityApi();
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final type = ; // ReactionType |
final userId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.getActivities(albumId, assetId, type, userId);
print(result);
} catch (e) {
print('Exception when calling ActivityApi->getActivities: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**albumId** | **String**| |
**assetId** | **String**| | [optional]
**type** | [**ReactionType**](.md)| | [optional]
**userId** | **String**| | [optional]
### Return type
[**List<ActivityResponseDto>**](ActivityResponseDto.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)
# **getActivityStatistics**
> ActivityStatisticsResponseDto getActivityStatistics(albumId, assetId)
### 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 = ActivityApi();
final albumId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
final assetId = 38400000-8cf0-11bd-b23e-10b96e4ef00d; // String |
try {
final result = api_instance.getActivityStatistics(albumId, assetId);
print(result);
} catch (e) {
print('Exception when calling ActivityApi->getActivityStatistics: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**albumId** | **String**| |
**assetId** | **String**| | [optional]
### Return type
[**ActivityStatisticsResponseDto**](ActivityStatisticsResponseDto.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)

18
mobile/openapi/doc/ActivityCreateDto.md generated Normal file
View File

@@ -0,0 +1,18 @@
# openapi.model.ActivityCreateDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**albumId** | **String** | |
**assetId** | **String** | | [optional]
**comment** | **String** | | [optional]
**type** | [**ReactionType**](ReactionType.md) | |
[[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

@@ -0,0 +1,20 @@
# openapi.model.ActivityResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**assetId** | **String** | |
**comment** | **String** | | [optional]
**createdAt** | [**DateTime**](DateTime.md) | |
**id** | **String** | |
**type** | **String** | |
**user** | [**UserDto**](UserDto.md) | |
[[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

@@ -0,0 +1,15 @@
# openapi.model.ActivityStatisticsResponseDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**comments** | **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)

View File

@@ -1,4 +1,4 @@
# openapi.model.UserCountResponseDto
# openapi.model.ReactionType
## Load the model package
```dart
@@ -8,7 +8,6 @@ import 'package:openapi/api.dart';
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**userCount** | **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)

View File

@@ -185,7 +185,7 @@ 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)
# **getMySharedLink**
> SharedLinkResponseDto getMySharedLink(key)
> SharedLinkResponseDto getMySharedLink(password, token, key)
@@ -208,10 +208,12 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = SharedLinkApi();
final password = password; // String |
final token = token_example; // String |
final key = key_example; // String |
try {
final result = api_instance.getMySharedLink(key);
final result = api_instance.getMySharedLink(password, token, key);
print(result);
} catch (e) {
print('Exception when calling SharedLinkApi->getMySharedLink: $e\n');
@@ -222,6 +224,8 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**password** | **String**| | [optional]
**token** | **String**| | [optional]
**key** | **String**| | [optional]
### Return type

View File

@@ -14,6 +14,7 @@ Name | Type | Description | Notes
**assetIds** | **List<String>** | | [optional] [default to const []]
**description** | **String** | | [optional]
**expiresAt** | [**DateTime**](DateTime.md) | | [optional]
**password** | **String** | | [optional]
**showMetadata** | **bool** | | [optional] [default to true]
**type** | [**SharedLinkType**](SharedLinkType.md) | |

View File

@@ -13,6 +13,7 @@ Name | Type | Description | Notes
**changeExpiryTime** | **bool** | Few clients cannot send null to set the expiryTime to never. Setting this flag and not sending expiryAt is considered as null instead. Clients that can send null values can ignore this. | [optional]
**description** | **String** | | [optional]
**expiresAt** | [**DateTime**](DateTime.md) | | [optional]
**password** | **String** | | [optional]
**showMetadata** | **bool** | | [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

@@ -17,7 +17,9 @@ Name | Type | Description | Notes
**expiresAt** | [**DateTime**](DateTime.md) | |
**id** | **String** | |
**key** | **String** | |
**password** | **String** | |
**showMetadata** | **bool** | |
**token** | **String** | | [optional]
**type** | [**SharedLinkType**](SharedLinkType.md) | |
**userId** | **String** | |

View File

@@ -10,6 +10,7 @@ Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**ffmpeg** | [**SystemConfigFFmpegDto**](SystemConfigFFmpegDto.md) | |
**job** | [**SystemConfigJobDto**](SystemConfigJobDto.md) | |
**library_** | [**SystemConfigLibraryDto**](SystemConfigLibraryDto.md) | |
**machineLearning** | [**SystemConfigMachineLearningDto**](SystemConfigMachineLearningDto.md) | |
**map** | [**SystemConfigMapDto**](SystemConfigMapDto.md) | |
**newVersionCheck** | [**SystemConfigNewVersionCheckDto**](SystemConfigNewVersionCheckDto.md) | |

View File

@@ -0,0 +1,15 @@
# openapi.model.SystemConfigLibraryDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**scan** | [**SystemConfigLibraryScanDto**](SystemConfigLibraryScanDto.md) | |
[[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

@@ -0,0 +1,16 @@
# openapi.model.SystemConfigLibraryScanDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**cronExpression** | **String** | |
**enabled** | **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

@@ -16,7 +16,6 @@ Method | HTTP request | Description
[**getMyUserInfo**](UserApi.md#getmyuserinfo) | **GET** /user/me |
[**getProfileImage**](UserApi.md#getprofileimage) | **GET** /user/profile-image/{id} |
[**getUserById**](UserApi.md#getuserbyid) | **GET** /user/info/{id} |
[**getUserCount**](UserApi.md#getusercount) | **GET** /user/count |
[**restoreUser**](UserApi.md#restoreuser) | **POST** /user/{id}/restore |
[**updateUser**](UserApi.md#updateuser) | **PUT** /user |
@@ -402,61 +401,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)
# **getUserCount**
> UserCountResponseDto getUserCount(admin)
### 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 = UserApi();
final admin = true; // bool |
try {
final result = api_instance.getUserCount(admin);
print(result);
} catch (e) {
print('Exception when calling UserApi->getUserCount: $e\n');
}
```
### Parameters
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**admin** | **bool**| | [optional] [default to false]
### Return type
[**UserCountResponseDto**](UserCountResponseDto.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)
# **restoreUser**
> UserResponseDto restoreUser(id)

19
mobile/openapi/doc/UserDto.md generated Normal file
View File

@@ -0,0 +1,19 @@
# openapi.model.UserDto
## Load the model package
```dart
import 'package:openapi/api.dart';
```
## Properties
Name | Type | Description | Notes
------------ | ------------- | ------------- | -------------
**email** | **String** | |
**firstName** | **String** | |
**id** | **String** | |
**lastName** | **String** | |
**profileImagePath** | **String** | |
[[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

@@ -29,6 +29,7 @@ part 'auth/http_basic_auth.dart';
part 'auth/http_bearer_auth.dart';
part 'api/api_key_api.dart';
part 'api/activity_api.dart';
part 'api/album_api.dart';
part 'api/asset_api.dart';
part 'api/audit_api.dart';
@@ -49,6 +50,9 @@ part 'model/api_key_create_dto.dart';
part 'model/api_key_create_response_dto.dart';
part 'model/api_key_response_dto.dart';
part 'model/api_key_update_dto.dart';
part 'model/activity_create_dto.dart';
part 'model/activity_response_dto.dart';
part 'model/activity_statistics_response_dto.dart';
part 'model/add_users_dto.dart';
part 'model/admin_signup_response_dto.dart';
part 'model/album_count_response_dto.dart';
@@ -129,6 +133,7 @@ part 'model/person_response_dto.dart';
part 'model/person_statistics_response_dto.dart';
part 'model/person_update_dto.dart';
part 'model/queue_status_dto.dart';
part 'model/reaction_type.dart';
part 'model/recognition_config.dart';
part 'model/scan_library_dto.dart';
part 'model/search_album_response_dto.dart';
@@ -156,6 +161,8 @@ part 'model/smart_info_response_dto.dart';
part 'model/system_config_dto.dart';
part 'model/system_config_f_fmpeg_dto.dart';
part 'model/system_config_job_dto.dart';
part 'model/system_config_library_dto.dart';
part 'model/system_config_library_scan_dto.dart';
part 'model/system_config_machine_learning_dto.dart';
part 'model/system_config_map_dto.dart';
part 'model/system_config_new_version_check_dto.dart';
@@ -182,7 +189,7 @@ part 'model/update_stack_parent_dto.dart';
part 'model/update_tag_dto.dart';
part 'model/update_user_dto.dart';
part 'model/usage_by_user_dto.dart';
part 'model/user_count_response_dto.dart';
part 'model/user_dto.dart';
part 'model/user_response_dto.dart';
part 'model/validate_access_token_response_dto.dart';
part 'model/video_codec.dart';

234
mobile/openapi/lib/api/activity_api.dart generated Normal file
View File

@@ -0,0 +1,234 @@
//
// 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 ActivityApi {
ActivityApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient;
final ApiClient apiClient;
/// Performs an HTTP 'POST /activity' operation and returns the [Response].
/// Parameters:
///
/// * [ActivityCreateDto] activityCreateDto (required):
Future<Response> createActivityWithHttpInfo(ActivityCreateDto activityCreateDto,) async {
// ignore: prefer_const_declarations
final path = r'/activity';
// ignore: prefer_final_locals
Object? postBody = activityCreateDto;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
const contentTypes = <String>['application/json'];
return apiClient.invokeAPI(
path,
'POST',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [ActivityCreateDto] activityCreateDto (required):
Future<ActivityResponseDto?> createActivity(ActivityCreateDto activityCreateDto,) async {
final response = await createActivityWithHttpInfo(activityCreateDto,);
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), 'ActivityResponseDto',) as ActivityResponseDto;
}
return null;
}
/// Performs an HTTP 'DELETE /activity/{id}' operation and returns the [Response].
/// Parameters:
///
/// * [String] id (required):
Future<Response> deleteActivityWithHttpInfo(String id,) async {
// ignore: prefer_const_declarations
final path = r'/activity/{id}'
.replaceAll('{id}', 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,
'DELETE',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] id (required):
Future<void> deleteActivity(String id,) async {
final response = await deleteActivityWithHttpInfo(id,);
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
}
/// Performs an HTTP 'GET /activity' operation and returns the [Response].
/// Parameters:
///
/// * [String] albumId (required):
///
/// * [String] assetId:
///
/// * [ReactionType] type:
///
/// * [String] userId:
Future<Response> getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
// ignore: prefer_const_declarations
final path = r'/activity';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'albumId', albumId));
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
if (userId != null) {
queryParams.addAll(_queryParams('', 'userId', userId));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] albumId (required):
///
/// * [String] assetId:
///
/// * [ReactionType] type:
///
/// * [String] userId:
Future<List<ActivityResponseDto>?> getActivities(String albumId, { String? assetId, ReactionType? type, String? userId, }) async {
final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, userId: userId, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}
// When a remote server returns no body with a status of 204, we shall not decode it.
// At the time of writing this, `dart:convert` will throw an "Unexpected end of input"
// FormatException when trying to decode an empty string.
if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) {
final responseBody = await _decodeBodyBytes(response);
return (await apiClient.deserializeAsync(responseBody, 'List<ActivityResponseDto>') as List)
.cast<ActivityResponseDto>()
.toList();
}
return null;
}
/// Performs an HTTP 'GET /activity/statistics' operation and returns the [Response].
/// Parameters:
///
/// * [String] albumId (required):
///
/// * [String] assetId:
Future<Response> getActivityStatisticsWithHttpInfo(String albumId, { String? assetId, }) async {
// ignore: prefer_const_declarations
final path = r'/activity/statistics';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
queryParams.addAll(_queryParams('', 'albumId', albumId));
if (assetId != null) {
queryParams.addAll(_queryParams('', 'assetId', assetId));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [String] albumId (required):
///
/// * [String] assetId:
Future<ActivityStatisticsResponseDto?> getActivityStatistics(String albumId, { String? assetId, }) async {
final response = await getActivityStatisticsWithHttpInfo(albumId, assetId: assetId, );
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), 'ActivityStatisticsResponseDto',) as ActivityStatisticsResponseDto;
}
return null;
}
}

View File

@@ -173,8 +173,12 @@ class SharedLinkApi {
/// Performs an HTTP 'GET /shared-link/me' operation and returns the [Response].
/// Parameters:
///
/// * [String] password:
///
/// * [String] token:
///
/// * [String] key:
Future<Response> getMySharedLinkWithHttpInfo({ String? key, }) async {
Future<Response> getMySharedLinkWithHttpInfo({ String? password, String? token, String? key, }) async {
// ignore: prefer_const_declarations
final path = r'/shared-link/me';
@@ -185,6 +189,12 @@ class SharedLinkApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (password != null) {
queryParams.addAll(_queryParams('', 'password', password));
}
if (token != null) {
queryParams.addAll(_queryParams('', 'token', token));
}
if (key != null) {
queryParams.addAll(_queryParams('', 'key', key));
}
@@ -205,9 +215,13 @@ class SharedLinkApi {
/// Parameters:
///
/// * [String] password:
///
/// * [String] token:
///
/// * [String] key:
Future<SharedLinkResponseDto?> getMySharedLink({ String? key, }) async {
final response = await getMySharedLinkWithHttpInfo( key: key, );
Future<SharedLinkResponseDto?> getMySharedLink({ String? password, String? token, String? key, }) async {
final response = await getMySharedLinkWithHttpInfo( password: password, token: token, key: key, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View File

@@ -357,57 +357,6 @@ class UserApi {
return null;
}
/// Performs an HTTP 'GET /user/count' operation and returns the [Response].
/// Parameters:
///
/// * [bool] admin:
Future<Response> getUserCountWithHttpInfo({ bool? admin, }) async {
// ignore: prefer_const_declarations
final path = r'/user/count';
// ignore: prefer_final_locals
Object? postBody;
final queryParams = <QueryParam>[];
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (admin != null) {
queryParams.addAll(_queryParams('', 'admin', admin));
}
const contentTypes = <String>[];
return apiClient.invokeAPI(
path,
'GET',
queryParams,
postBody,
headerParams,
formParams,
contentTypes.isEmpty ? null : contentTypes.first,
);
}
/// Parameters:
///
/// * [bool] admin:
Future<UserCountResponseDto?> getUserCount({ bool? admin, }) async {
final response = await getUserCountWithHttpInfo( admin: admin, );
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), 'UserCountResponseDto',) as UserCountResponseDto;
}
return null;
}
/// Performs an HTTP 'POST /user/{id}/restore' operation and returns the [Response].
/// Parameters:
///

View File

@@ -189,6 +189,12 @@ class ApiClient {
return APIKeyResponseDto.fromJson(value);
case 'APIKeyUpdateDto':
return APIKeyUpdateDto.fromJson(value);
case 'ActivityCreateDto':
return ActivityCreateDto.fromJson(value);
case 'ActivityResponseDto':
return ActivityResponseDto.fromJson(value);
case 'ActivityStatisticsResponseDto':
return ActivityStatisticsResponseDto.fromJson(value);
case 'AddUsersDto':
return AddUsersDto.fromJson(value);
case 'AdminSignupResponseDto':
@@ -349,6 +355,8 @@ class ApiClient {
return PersonUpdateDto.fromJson(value);
case 'QueueStatusDto':
return QueueStatusDto.fromJson(value);
case 'ReactionType':
return ReactionTypeTypeTransformer().decode(value);
case 'RecognitionConfig':
return RecognitionConfig.fromJson(value);
case 'ScanLibraryDto':
@@ -403,6 +411,10 @@ class ApiClient {
return SystemConfigFFmpegDto.fromJson(value);
case 'SystemConfigJobDto':
return SystemConfigJobDto.fromJson(value);
case 'SystemConfigLibraryDto':
return SystemConfigLibraryDto.fromJson(value);
case 'SystemConfigLibraryScanDto':
return SystemConfigLibraryScanDto.fromJson(value);
case 'SystemConfigMachineLearningDto':
return SystemConfigMachineLearningDto.fromJson(value);
case 'SystemConfigMapDto':
@@ -455,8 +467,8 @@ class ApiClient {
return UpdateUserDto.fromJson(value);
case 'UsageByUserDto':
return UsageByUserDto.fromJson(value);
case 'UserCountResponseDto':
return UserCountResponseDto.fromJson(value);
case 'UserDto':
return UserDto.fromJson(value);
case 'UserResponseDto':
return UserResponseDto.fromJson(value);
case 'ValidateAccessTokenResponseDto':

View File

@@ -97,6 +97,9 @@ String parameterToString(dynamic value) {
if (value is PathType) {
return PathTypeTypeTransformer().encode(value).toString();
}
if (value is ReactionType) {
return ReactionTypeTypeTransformer().encode(value).toString();
}
if (value is SharedLinkType) {
return SharedLinkTypeTypeTransformer().encode(value).toString();
}

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