From cf4ec067505e3af84bce490e65d4e8c7b609ba3b Mon Sep 17 00:00:00 2001 From: Alex The Bot Date: Wed, 1 Nov 2023 14:46:59 +0000 Subject: [PATCH 01/16] Version v1.84.0 --- cli/src/api/open-api/api.ts | 2 +- cli/src/api/open-api/base.ts | 2 +- cli/src/api/open-api/common.ts | 2 +- cli/src/api/open-api/configuration.ts | 2 +- cli/src/api/open-api/index.ts | 2 +- machine-learning/pyproject.toml | 2 +- mobile/android/fastlane/Fastfile | 4 ++-- mobile/ios/fastlane/Fastfile | 2 +- mobile/openapi/README.md | 2 +- mobile/pubspec.yaml | 2 +- server/immich-openapi-specs.json | 2 +- server/package-lock.json | 4 ++-- server/package.json | 2 +- web/src/api/open-api/api.ts | 2 +- web/src/api/open-api/base.ts | 2 +- web/src/api/open-api/common.ts | 2 +- web/src/api/open-api/configuration.ts | 2 +- web/src/api/open-api/index.ts | 2 +- 18 files changed, 20 insertions(+), 20 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index f64a592b5..8d9181e2f 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -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). diff --git a/cli/src/api/open-api/base.ts b/cli/src/api/open-api/base.ts index 814dc8f92..9a534e7bd 100644 --- a/cli/src/api/open-api/base.ts +++ b/cli/src/api/open-api/base.ts @@ -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). diff --git a/cli/src/api/open-api/common.ts b/cli/src/api/open-api/common.ts index 7d762d6ac..8997b2d52 100644 --- a/cli/src/api/open-api/common.ts +++ b/cli/src/api/open-api/common.ts @@ -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). diff --git a/cli/src/api/open-api/configuration.ts b/cli/src/api/open-api/configuration.ts index 3ace8a93f..8058881d1 100644 --- a/cli/src/api/open-api/configuration.ts +++ b/cli/src/api/open-api/configuration.ts @@ -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). diff --git a/cli/src/api/open-api/index.ts b/cli/src/api/open-api/index.ts index 62d163db6..d0651f28a 100644 --- a/cli/src/api/open-api/index.ts +++ b/cli/src/api/open-api/index.ts @@ -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). diff --git a/machine-learning/pyproject.toml b/machine-learning/pyproject.toml index 272724d91..6fea1e5d5 100644 --- a/machine-learning/pyproject.toml +++ b/machine-learning/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "machine-learning" -version = "1.83.0" +version = "1.84.0" description = "" authors = ["Hau Tran "] readme = "README.md" diff --git a/mobile/android/fastlane/Fastfile b/mobile/android/fastlane/Fastfile index cd01d8761..281fa52d2 100644 --- a/mobile/android/fastlane/Fastfile +++ b/mobile/android/fastlane/Fastfile @@ -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') diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 5ce704794..19cefc12f 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -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, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index d11037497..f4d32ac30 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -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 diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index a38797830..63c6f312f 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: "none" -version: 1.83.0+107 +version: 1.84.0+108 isar_version: &isar_version 3.1.0+1 environment: diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 2ed01f30e..a78621c28 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -5612,7 +5612,7 @@ "info": { "title": "Immich", "description": "Immich API", - "version": "1.83.0", + "version": "1.84.0", "contact": {} }, "tags": [], diff --git a/server/package-lock.json b/server/package-lock.json index 7cebecaf8..6eb1fb7f3 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -1,12 +1,12 @@ { "name": "immich", - "version": "1.83.0", + "version": "1.84.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "immich", - "version": "1.83.0", + "version": "1.84.0", "license": "UNLICENSED", "dependencies": { "@babel/runtime": "^7.22.11", diff --git a/server/package.json b/server/package.json index 182d7f7d5..f073c738a 100644 --- a/server/package.json +++ b/server/package.json @@ -1,6 +1,6 @@ { "name": "immich", - "version": "1.83.0", + "version": "1.84.0", "description": "", "author": "", "private": true, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index f64a592b5..8d9181e2f 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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). diff --git a/web/src/api/open-api/base.ts b/web/src/api/open-api/base.ts index 814dc8f92..9a534e7bd 100644 --- a/web/src/api/open-api/base.ts +++ b/web/src/api/open-api/base.ts @@ -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). diff --git a/web/src/api/open-api/common.ts b/web/src/api/open-api/common.ts index 7d762d6ac..8997b2d52 100644 --- a/web/src/api/open-api/common.ts +++ b/web/src/api/open-api/common.ts @@ -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). diff --git a/web/src/api/open-api/configuration.ts b/web/src/api/open-api/configuration.ts index 3ace8a93f..8058881d1 100644 --- a/web/src/api/open-api/configuration.ts +++ b/web/src/api/open-api/configuration.ts @@ -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). diff --git a/web/src/api/open-api/index.ts b/web/src/api/open-api/index.ts index 62d163db6..d0651f28a 100644 --- a/web/src/api/open-api/index.ts +++ b/web/src/api/open-api/index.ts @@ -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). From 0130591a0f7fb71da99bc05bc0f319137b1c00bd Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 1 Nov 2023 11:49:12 -0400 Subject: [PATCH 02/16] fix: show/set activity like per user (#4775) * fix: like per user * chore: open api * chore: e2e test for userId filtering --- cli/src/api/open-api/api.ts | 23 +++++++++++++++---- mobile/openapi/doc/ActivityApi.md | 6 +++-- mobile/openapi/lib/api/activity_api.dart | 13 ++++++++--- mobile/openapi/test/activity_api_test.dart | 2 +- server/immich-openapi-specs.json | 9 ++++++++ server/src/domain/activity/activity.dto.ts | 3 +++ .../src/domain/activity/activity.service.ts | 1 + server/test/e2e/activity.e2e-spec.ts | 23 +++++++++++++++++++ web/src/api/open-api/api.ts | 23 +++++++++++++++---- .../asset-viewer/asset-viewer.svelte | 3 ++- 10 files changed, 89 insertions(+), 17 deletions(-) diff --git a/cli/src/api/open-api/api.ts b/cli/src/api/open-api/api.ts index 8d9181e2f..b00b0deb5 100644 --- a/cli/src/api/open-api/api.ts +++ b/cli/src/api/open-api/api.ts @@ -5076,10 +5076,11 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat * @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, options: AxiosRequestConfig = {}): Promise => { + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, userId?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'albumId' is not null or undefined assertParamExists('getActivities', 'albumId', albumId) const localVarPath = `/activity`; @@ -5115,6 +5116,10 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat localVarQueryParameter['type'] = type; } + if (userId !== undefined) { + localVarQueryParameter['userId'] = userId; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -5211,11 +5216,12 @@ export const ActivityApiFp = function(configuration?: 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, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options); + async getActivities(albumId: string, assetId?: string, type?: ReactionType, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, userId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -5264,7 +5270,7 @@ export const ActivityApiFactory = function (configuration?: Configuration, baseP * @throws {RequiredError} */ getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath)); + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(axios, basePath)); }, /** * @@ -5332,6 +5338,13 @@ export interface ActivityApiGetActivitiesRequest { * @memberof ActivityApiGetActivities */ readonly type?: ReactionType + + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly userId?: string } /** @@ -5392,7 +5405,7 @@ export class ActivityApi extends BaseAPI { * @memberof ActivityApi */ public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { - return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath)); + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/mobile/openapi/doc/ActivityApi.md b/mobile/openapi/doc/ActivityApi.md index 8ae91efc2..1af3f1f49 100644 --- a/mobile/openapi/doc/ActivityApi.md +++ b/mobile/openapi/doc/ActivityApi.md @@ -125,7 +125,7 @@ void (empty response body) [[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 getActivities(albumId, assetId, type) +> List getActivities(albumId, assetId, type, userId) @@ -151,9 +151,10 @@ 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); + final result = api_instance.getActivities(albumId, assetId, type, userId); print(result); } catch (e) { print('Exception when calling ActivityApi->getActivities: $e\n'); @@ -167,6 +168,7 @@ Name | Type | Description | Notes **albumId** | **String**| | **assetId** | **String**| | [optional] **type** | [**ReactionType**](.md)| | [optional] + **userId** | **String**| | [optional] ### Return type diff --git a/mobile/openapi/lib/api/activity_api.dart b/mobile/openapi/lib/api/activity_api.dart index fa04c68b5..458538a5d 100644 --- a/mobile/openapi/lib/api/activity_api.dart +++ b/mobile/openapi/lib/api/activity_api.dart @@ -111,7 +111,9 @@ class ActivityApi { /// * [String] assetId: /// /// * [ReactionType] type: - Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, }) async { + /// + /// * [String] userId: + Future getActivitiesWithHttpInfo(String albumId, { String? assetId, ReactionType? type, String? userId, }) async { // ignore: prefer_const_declarations final path = r'/activity'; @@ -129,6 +131,9 @@ class ActivityApi { if (type != null) { queryParams.addAll(_queryParams('', 'type', type)); } + if (userId != null) { + queryParams.addAll(_queryParams('', 'userId', userId)); + } const contentTypes = []; @@ -151,8 +156,10 @@ class ActivityApi { /// * [String] assetId: /// /// * [ReactionType] type: - Future?> getActivities(String albumId, { String? assetId, ReactionType? type, }) async { - final response = await getActivitiesWithHttpInfo(albumId, assetId: assetId, type: type, ); + /// + /// * [String] userId: + Future?> 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)); } diff --git a/mobile/openapi/test/activity_api_test.dart b/mobile/openapi/test/activity_api_test.dart index 401264c2b..9b6fe1a6c 100644 --- a/mobile/openapi/test/activity_api_test.dart +++ b/mobile/openapi/test/activity_api_test.dart @@ -27,7 +27,7 @@ void main() { // TODO }); - //Future> getActivities(String albumId, { String assetId, ReactionType type }) async + //Future> getActivities(String albumId, { String assetId, ReactionType type, String userId }) async test('test getActivities', () async { // TODO }); diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index a78621c28..822513720 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -30,6 +30,15 @@ "schema": { "$ref": "#/components/schemas/ReactionType" } + }, + { + "name": "userId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "type": "string" + } } ], "responses": { diff --git a/server/src/domain/activity/activity.dto.ts b/server/src/domain/activity/activity.dto.ts index 894c9b29c..e1a163b81 100644 --- a/server/src/domain/activity/activity.dto.ts +++ b/server/src/domain/activity/activity.dto.ts @@ -38,6 +38,9 @@ export class ActivitySearchDto extends ActivityDto { @Optional() @ApiProperty({ enumName: 'ReactionType', enum: ReactionType }) type?: ReactionType; + + @ValidateUUID({ optional: true }) + userId?: string; } const isComment = (dto: ActivityCreateDto) => dto.type === 'comment'; diff --git a/server/src/domain/activity/activity.service.ts b/server/src/domain/activity/activity.service.ts index e5af6169a..bacdab317 100644 --- a/server/src/domain/activity/activity.service.ts +++ b/server/src/domain/activity/activity.service.ts @@ -28,6 +28,7 @@ export class ActivityService { async getAll(authUser: AuthUserDto, dto: ActivitySearchDto): Promise { await this.access.requirePermission(authUser, Permission.ALBUM_READ, dto.albumId); const activities = await this.repository.search({ + userId: dto.userId, albumId: dto.albumId, assetId: dto.assetId, isLiked: dto.type && dto.type === ReactionType.LIKE, diff --git a/server/test/e2e/activity.e2e-spec.ts b/server/test/e2e/activity.e2e-spec.ts index c488630ec..5cc86fc6a 100644 --- a/server/test/e2e/activity.e2e-spec.ts +++ b/server/test/e2e/activity.e2e-spec.ts @@ -134,6 +134,29 @@ describe(`${ActivityController.name} (e2e)`, () => { expect(body[0]).toEqual(reaction); }); + it('should filter by userId', async () => { + const [reaction] = await Promise.all([ + api.activityApi.create(server, admin.accessToken, { albumId: album.id, type: ReactionType.LIKE }), + ]); + + const response1 = await request(server) + .get('/activity') + .query({ albumId: album.id, userId: uuidStub.notFound }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(response1.status).toEqual(200); + expect(response1.body.length).toBe(0); + + const response2 = await request(server) + .get('/activity') + .query({ albumId: album.id, userId: admin.userId }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(response2.status).toEqual(200); + expect(response2.body.length).toBe(1); + expect(response2.body[0]).toEqual(reaction); + }); + it('should filter by assetId', async () => { const [reaction] = await Promise.all([ api.activityApi.create(server, admin.accessToken, { diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index 8d9181e2f..b00b0deb5 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -5076,10 +5076,11 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat * @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, options: AxiosRequestConfig = {}): Promise => { + getActivities: async (albumId: string, assetId?: string, type?: ReactionType, userId?: string, options: AxiosRequestConfig = {}): Promise => { // verify required parameter 'albumId' is not null or undefined assertParamExists('getActivities', 'albumId', albumId) const localVarPath = `/activity`; @@ -5115,6 +5116,10 @@ export const ActivityApiAxiosParamCreator = function (configuration?: Configurat localVarQueryParameter['type'] = type; } + if (userId !== undefined) { + localVarQueryParameter['userId'] = userId; + } + setSearchParams(localVarUrlObj, localVarQueryParameter); @@ -5211,11 +5216,12 @@ export const ActivityApiFp = function(configuration?: 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, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, options); + async getActivities(albumId: string, assetId?: string, type?: ReactionType, userId?: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + const localVarAxiosArgs = await localVarAxiosParamCreator.getActivities(albumId, assetId, type, userId, options); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); }, /** @@ -5264,7 +5270,7 @@ export const ActivityApiFactory = function (configuration?: Configuration, baseP * @throws {RequiredError} */ getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig): AxiosPromise> { - return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(axios, basePath)); + return localVarFp.getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(axios, basePath)); }, /** * @@ -5332,6 +5338,13 @@ export interface ActivityApiGetActivitiesRequest { * @memberof ActivityApiGetActivities */ readonly type?: ReactionType + + /** + * + * @type {string} + * @memberof ActivityApiGetActivities + */ + readonly userId?: string } /** @@ -5392,7 +5405,7 @@ export class ActivityApi extends BaseAPI { * @memberof ActivityApi */ public getActivities(requestParameters: ActivityApiGetActivitiesRequest, options?: AxiosRequestConfig) { - return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, options).then((request) => request(this.axios, this.basePath)); + return ActivityApiFp(this.configuration).getActivities(requestParameters.albumId, requestParameters.assetId, requestParameters.type, requestParameters.userId, options).then((request) => request(this.axios, this.basePath)); } /** diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 8ececaf1d..e4cceb664 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -123,9 +123,10 @@ }; const getFavorite = async () => { - if (album) { + if (album && user) { try { const { data } = await api.activityApi.getActivities({ + userId: user.id, assetId: asset.id, albumId: album.id, type: ReactionType.Like, From 309bf1ad22ee7f51e386da9bda70ff15aa5bbc02 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 1 Nov 2023 14:43:10 -0500 Subject: [PATCH 03/16] chore: post release tasks --- mobile/android/fastlane/report.xml | 6 +++--- mobile/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- mobile/ios/Runner/Info.plist | 4 ++-- mobile/ios/fastlane/report.xml | 12 ++++++------ 4 files changed, 14 insertions(+), 14 deletions(-) diff --git a/mobile/android/fastlane/report.xml b/mobile/android/fastlane/report.xml index 7b7db96a2..a0530d9aa 100644 --- a/mobile/android/fastlane/report.xml +++ b/mobile/android/fastlane/report.xml @@ -5,17 +5,17 @@ - + - + - + diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 8639b13ad..b15f480e2 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -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; diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 022cf3886..a6fddc98a 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -59,11 +59,11 @@ CFBundlePackageType APPL CFBundleShortVersionString - 1.78.1 + 1.84.0 CFBundleSignature ???? CFBundleVersion - 118 + 124 FLTEnableImpeller ITSAppUsesNonExemptEncryption diff --git a/mobile/ios/fastlane/report.xml b/mobile/ios/fastlane/report.xml index c640150e6..c61f1d5d2 100644 --- a/mobile/ios/fastlane/report.xml +++ b/mobile/ios/fastlane/report.xml @@ -5,32 +5,32 @@ - + - + - + - + - + - + From 1d35965d03c318ed6a32a151f2309df311ab9d41 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Wed, 1 Nov 2023 21:34:30 -0400 Subject: [PATCH 04/16] 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 --- .../asset-viewer/asset-viewer.svelte | 166 ++++++++++++------ .../asset-viewer/slideshow-bar.svelte | 78 ++++++++ web/src/lib/stores/assets.store.ts | 13 ++ web/src/lib/stores/slideshow.store.ts | 45 +++++ web/src/lib/utils/slideshow-history.ts | 40 +++++ .../(user)/albums/[albumId]/+page.svelte | 15 +- 6 files changed, 298 insertions(+), 59 deletions(-) create mode 100644 web/src/lib/components/asset-viewer/slideshow-bar.svelte create mode 100644 web/src/lib/stores/slideshow.store.ts create mode 100644 web/src/lib/utils/slideshow-history.ts diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index e4cceb664..27597153c 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -29,25 +29,24 @@ import { browser } from '$app/environment'; import { handleError } from '$lib/utils/handle-error'; import type { AssetStore } from '$lib/stores/assets.store'; - import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; - import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; import { shouldIgnoreShortcut } from '$lib/utils/shortcut'; + import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { SlideshowHistory } from '$lib/utils/slideshow-history'; import { featureFlags } from '$lib/stores/server-config.store'; import { - mdiChevronLeft, mdiHeartOutline, mdiHeart, mdiCommentOutline, + mdiChevronLeft, mdiChevronRight, - mdiClose, mdiImageBrokenVariant, - mdiPause, - mdiPlay, } from '@mdi/js'; import Icon from '$lib/components/elements/icon.svelte'; import Thumbnail from '../assets/thumbnail/thumbnail.svelte'; import { stackAssetsStore } from '$lib/stores/stacked-asset.store'; import ActivityViewer from './activity-viewer.svelte'; + import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; + import SlideshowBar from './slideshow-bar.svelte'; export let assetStore: AssetStore | null = null; export let asset: AssetResponseDto; @@ -62,6 +61,14 @@ let reactions: ActivityResponseDto[] = []; + const { setAssetId } = assetViewingStore; + const { + restartProgress: restartSlideshowProgress, + stopProgress: stopSlideshowProgress, + slideshowShuffle, + slideshowState, + } = slideshowStore; + const dispatch = createEventDispatcher<{ archived: AssetResponseDto; unarchived: AssetResponseDto; @@ -82,6 +89,8 @@ let shouldShowDownloadButton = sharedLink ? sharedLink.allowDownload : !asset.isOffline; let shouldShowDetailButton = asset.hasMetadata; let canCopyImagesToClipboard: boolean; + let slideshowStateUnsubscribe: () => void; + let shuffleSlideshowUnsubscribe: () => void; let previewStackedAsset: AssetResponseDto | undefined; let isShowActivity = false; let isLiked: ActivityResponseDto | null = null; @@ -162,6 +171,23 @@ onMount(async () => { document.addEventListener('keydown', onKeyboardPress); + slideshowStateUnsubscribe = slideshowState.subscribe((value) => { + if (value === SlideshowState.PlaySlideshow) { + slideshowHistory.reset(); + slideshowHistory.queue(asset.id); + handlePlaySlideshow(); + } else if (value === SlideshowState.StopSlideshow) { + handleStopSlideshow(); + } + }); + + shuffleSlideshowUnsubscribe = slideshowShuffle.subscribe((value) => { + if (value) { + slideshowHistory.reset(); + slideshowHistory.queue(asset.id); + } + }); + if (!sharedLink) { await getAllAlbums(); } @@ -185,6 +211,14 @@ if (browser) { document.removeEventListener('keydown', onKeyboardPress); } + + if (slideshowStateUnsubscribe) { + slideshowStateUnsubscribe(); + } + + if (shuffleSlideshowUnsubscribe) { + shuffleSlideshowUnsubscribe(); + } }); $: asset.id && !sharedLink && getAllAlbums(); // Update the album information when the asset ID changes @@ -263,11 +297,31 @@ const closeViewer = () => dispatch('close'); + const navigateAssetRandom = async () => { + if (!assetStore) { + return; + } + + const asset = await assetStore.getRandomAsset(); + if (!asset) { + return; + } + + slideshowHistory.queue(asset.id); + + setAssetId(asset.id); + $restartSlideshowProgress = true; + }; + const navigateAssetForward = async (e?: Event) => { - if (isSlideshowMode && assetStore && progressBar) { + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) { + return slideshowHistory.next() || navigateAssetRandom(); + } + + if ($slideshowState === SlideshowState.PlaySlideshow && assetStore) { const hasNext = await assetStore.getNextAssetId(asset.id); if (hasNext) { - progressBar.restart(true); + $restartSlideshowProgress = true; } else { await handleStopSlideshow(); } @@ -278,8 +332,13 @@ }; const navigateAssetBackward = (e?: Event) => { - if (isSlideshowMode && progressBar) { - progressBar.restart(true); + if ($slideshowState === SlideshowState.PlaySlideshow && $slideshowShuffle) { + slideshowHistory.previous(); + return; + } + + if ($slideshowState === SlideshowState.PlaySlideshow) { + $restartSlideshowProgress = true; } e?.stopPropagation(); @@ -427,19 +486,21 @@ * Slide show mode */ - let isSlideshowMode = false; let assetViewerHtmlElement: HTMLElement; - let progressBar: ProgressBar; - let progressBarStatus: ProgressBarStatus; + + const slideshowHistory = new SlideshowHistory((assetId: string) => { + setAssetId(assetId); + $restartSlideshowProgress = true; + }); const handleVideoStarted = () => { - if (isSlideshowMode) { - progressBar.restart(false); + if ($slideshowState === SlideshowState.PlaySlideshow) { + $stopSlideshowProgress = true; } }; const handleVideoEnded = async () => { - if (isSlideshowMode) { + if ($slideshowState === SlideshowState.PlaySlideshow) { await navigateAssetForward(); } }; @@ -449,19 +510,20 @@ await assetViewerHtmlElement.requestFullscreen(); } catch (error) { console.error('Error entering fullscreen', error); - } finally { - isSlideshowMode = true; + $slideshowState = SlideshowState.StopSlideshow; } }; const handleStopSlideshow = async () => { try { - await document.exitFullscreen(); + if (document.fullscreenElement) { + await document.exitFullscreen(); + } } catch (error) { console.error('Error exiting fullscreen', error); } finally { - isSlideshowMode = false; - progressBar.restart(false); + $stopSlideshowProgress = true; + $slideshowState = SlideshowState.None; } }; @@ -498,31 +560,10 @@
-
- {#if isSlideshowMode} - -
-
- - (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} - title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} - /> - - -
- -
- {:else} + + {#if $slideshowState === SlideshowState.None} +
(isShowProfileImageCrop = true)} on:runJob={({ detail: job }) => handleRunJob(job)} - on:playSlideShow={handlePlaySlideshow} + on:playSlideShow={() => ($slideshowState = SlideshowState.PlaySlideshow)} on:unstack={handleUnstack} /> - {/if} -
+
+ {/if} - {#if !isSlideshowMode && showNavigation} -
+ {#if $slideshowState === SlideshowState.None && showNavigation} +
{/if} + -
+
+ {#if $slideshowState != SlideshowState.None} +
+ ($slideshowState = SlideshowState.StopSlideshow)} + /> +
+ {/if} + {#if previewStackedAsset} {#key previewStackedAsset.id} {#if previewStackedAsset.type === AssetTypeEnum.Image} @@ -603,7 +655,7 @@ on:onVideoStarted={handleVideoStarted} /> {/if} - {#if isShared} + {#if $slideshowState === SlideshowState.None && isShared}
- - - {#if !isSlideshowMode && showNavigation} -
+ {#if $slideshowState === SlideshowState.None && showNavigation} +
{/if} - {#if !isSlideshowMode && $isShowDetail} + {#if $slideshowState === SlideshowState.None && $isShowDetail}
+ import CircleIconButton from '../elements/buttons/circle-icon-button.svelte'; + import ProgressBar, { ProgressBarStatus } from '../shared-components/progress-bar/progress-bar.svelte'; + import { slideshowStore } from '$lib/stores/slideshow.store'; + import { createEventDispatcher, onDestroy, onMount } from 'svelte'; + import { + mdiChevronLeft, + mdiChevronRight, + mdiClose, + mdiPause, + mdiPlay, + mdiShuffle, + mdiShuffleDisabled, + } from '@mdi/js'; + + const { slideshowShuffle } = slideshowStore; + const { restartProgress, stopProgress } = slideshowStore; + + let progressBarStatus: ProgressBarStatus; + let progressBar: ProgressBar; + + let unsubscribeRestart: () => void; + let unsubscribeStop: () => void; + + const dispatch = createEventDispatcher<{ + next: void; + prev: void; + close: void; + }>(); + + onMount(() => { + unsubscribeRestart = restartProgress.subscribe((value) => { + if (value) { + progressBar.restart(value); + } + }); + + unsubscribeStop = stopProgress.subscribe((value) => { + if (value) { + progressBar.restart(false); + } + }); + }); + + onDestroy(() => { + if (unsubscribeRestart) { + unsubscribeRestart(); + } + + if (unsubscribeStop) { + unsubscribeStop(); + } + }); + + +
+ dispatch('close')} title="Exit Slideshow" /> + {#if $slideshowShuffle} + ($slideshowShuffle = false)} title="Shuffle" /> + {:else} + ($slideshowShuffle = true)} title="No shuffle" /> + {/if} + (progressBarStatus === ProgressBarStatus.Paused ? progressBar.play() : progressBar.pause())} + title={progressBarStatus === ProgressBarStatus.Paused ? 'Play' : 'Pause'} + /> + dispatch('prev')} title="Previous" /> + dispatch('next')} title="Next" /> +
+ + dispatch('next')} + duration={5000} +/> diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index 431019f4a..fa5c1ccdf 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -304,6 +304,19 @@ export class AssetStore { return this.assetToBucket[assetId]?.bucketIndex ?? null; } + async getRandomAsset(): Promise { + const bucket = this.buckets[Math.floor(Math.random() * this.buckets.length)] || null; + if (!bucket) { + return null; + } + + if (bucket.assets.length === 0) { + await this.loadBucket(bucket.bucketDate, BucketPosition.Unknown); + } + + return bucket.assets[Math.floor(Math.random() * bucket.assets.length)] || null; + } + updateAsset(_asset: AssetResponseDto) { const asset = this.assets.find((asset) => asset.id === _asset.id); if (!asset) { diff --git a/web/src/lib/stores/slideshow.store.ts b/web/src/lib/stores/slideshow.store.ts new file mode 100644 index 000000000..45b570c2e --- /dev/null +++ b/web/src/lib/stores/slideshow.store.ts @@ -0,0 +1,45 @@ +import { persisted } from 'svelte-local-storage-store'; +import { writable } from 'svelte/store'; + +export enum SlideshowState { + PlaySlideshow = 'play-slideshow', + StopSlideshow = 'stop-slideshow', + None = 'none', +} + +function createSlideshowStore() { + const restartState = writable(false); + const stopState = writable(false); + + const slideshowShuffle = persisted('slideshow-shuffle', true); + const slideshowState = writable(SlideshowState.None); + + return { + restartProgress: { + subscribe: restartState.subscribe, + set: (value: boolean) => { + // Trigger an action whenever the restartProgress is set to true. Automatically + // reset the restart state after that + if (value) { + restartState.set(true); + restartState.set(false); + } + }, + }, + stopProgress: { + subscribe: stopState.subscribe, + set: (value: boolean) => { + // Trigger an action whenever the stopProgress is set to true. Automatically + // reset the stop state after that + if (value) { + stopState.set(true); + stopState.set(false); + } + }, + }, + slideshowShuffle, + slideshowState, + }; +} + +export const slideshowStore = createSlideshowStore(); diff --git a/web/src/lib/utils/slideshow-history.ts b/web/src/lib/utils/slideshow-history.ts new file mode 100644 index 000000000..8b34359d0 --- /dev/null +++ b/web/src/lib/utils/slideshow-history.ts @@ -0,0 +1,40 @@ +export class SlideshowHistory { + private history: string[] = []; + private index = 0; + + constructor(private onChange: (assetId: string) => void) {} + + reset() { + this.history = []; + this.index = 0; + } + + queue(assetId: string) { + this.history.push(assetId); + + // If we were at the end of the slideshow history, move the index to the new end + if (this.index === this.history.length - 2) { + this.index++; + } + } + + next(): boolean { + if (this.index === this.history.length - 1) { + return false; + } + + this.index++; + this.onChange(this.history[this.index]); + return true; + } + + previous(): boolean { + if (this.index === 0) { + return false; + } + + this.index--; + this.onChange(this.history[this.index]); + return true; + } +} diff --git a/web/src/routes/(user)/albums/[albumId]/+page.svelte b/web/src/routes/(user)/albums/[albumId]/+page.svelte index 9b81c377c..1697af522 100644 --- a/web/src/routes/(user)/albums/[albumId]/+page.svelte +++ b/web/src/routes/(user)/albums/[albumId]/+page.svelte @@ -29,6 +29,7 @@ import { AppRoute, dateFormats } from '$lib/constants'; import { createAssetInteractionStore } from '$lib/stores/asset-interaction.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store'; + import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { AssetStore } from '$lib/stores/assets.store'; import { locale } from '$lib/stores/preferences.store'; import { downloadArchive } from '$lib/utils/asset-utils'; @@ -52,7 +53,8 @@ export let data: PageData; - let { isViewing: showAssetViewer } = assetViewingStore; + let { isViewing: showAssetViewer, setAssetId } = assetViewingStore; + let { slideshowState, slideshowShuffle } = slideshowStore; let album = data.album; $: album = data.album; @@ -108,6 +110,14 @@ } }); + const handleStartSlideshow = async () => { + const asset = $slideshowShuffle ? await assetStore.getRandomAsset() : assetStore.assets[0]; + if (asset) { + setAssetId(asset.id); + $slideshowState = SlideshowState.PlaySlideshow; + } + }; + const handleEscape = () => { if (viewMode === ViewMode.SELECT_USERS) { viewMode = ViewMode.VIEW; @@ -365,6 +375,9 @@ {#if viewMode === ViewMode.ALBUM_OPTIONS} + {#if album.assetCount !== 0} + + {/if} (viewMode = ViewMode.SELECT_THUMBNAIL)} text="Set album cover" /> {/if} From d8903de92ea4e7d76aae43d9afac7ac99bfe974d Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 1 Nov 2023 20:49:57 -0500 Subject: [PATCH 05/16] docs: remove read-only related content (#4781) * docs: remove read-only related content * format * broken link --- docs/blog/2023/06-24/update.mdx | 2 - docs/docs/features/bulk-upload.md | 68 ----------------- docs/docs/features/read-only-gallery.md | 97 ------------------------- 3 files changed, 167 deletions(-) delete mode 100644 docs/docs/features/read-only-gallery.md diff --git a/docs/blog/2023/06-24/update.mdx b/docs/blog/2023/06-24/update.mdx index ea948321d..d4c714943 100644 --- a/docs/blog/2023/06-24/update.mdx +++ b/docs/blog/2023/06-24/update.mdx @@ -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. diff --git a/docs/docs/features/bulk-upload.md b/docs/docs/features/bulk-upload.md index aed3b2ec9..5923e5c16 100644 --- a/docs/docs/features/bulk-upload.md +++ b/docs/docs/features/bulk-upload.md @@ -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 -``` - -::: diff --git a/docs/docs/features/read-only-gallery.md b/docs/docs/features/read-only-gallery.md deleted file mode 100644 index 7b1cd605f..000000000 --- a/docs/docs/features/read-only-gallery.md +++ /dev/null @@ -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. - - - - - -### 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 --server http://my-server-ip:2283/api /mnt/media/precious-memory --recursive --import -``` - -```bash title="Import my wife gallery" -immich upload --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. From d5f8199655865d8665e7efe6d867313d21de4041 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 1 Nov 2023 20:50:24 -0500 Subject: [PATCH 06/16] fix(web): scrollbar not showing year (#4782) * fix(web): scrollbar not showing year * grammar * fix test --- .../scrollbar/scrollbar.svelte | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte index 750b3247b..24490329b 100644 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte @@ -96,6 +96,19 @@ {@const date = fromLocalDateTime(segment.timeGroup)} {@const year = date.year} {@const label = `${date.toLocaleString({ month: 'short' })} ${year}`} + {@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year} + + + {@const canRenderYear = segments.slice(index + 1, index + 3).reduce((_, curr) => { + const nextGroupYear = fromLocalDateTime(curr.timeGroup).year; + + if (nextGroupYear !== year || curr.height < 1) { + return false; + } + + return true; + }, true)}
(hoverLabel = label)} > - {#if new Date(segments[index - 1]?.timeGroup).getFullYear() !== year} + {#if lastGroupYear !== year && canRenderYear}
{year}
From 2b9f20a1b5e0ddb043df8589212e347ee601d5e2 Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Thu, 2 Nov 2023 19:43:27 +0100 Subject: [PATCH 07/16] fix: update like status (#4803) --- web/src/lib/components/asset-viewer/asset-viewer.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 27597153c..c519346e8 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -142,6 +142,8 @@ }); if (data.length > 0) { isLiked = data[0]; + } else { + isLiked = null; } } catch (error) { handleError(error, "Can't get Favorite"); From b58edae134045b71582422ffc09c9e9ce9b099fa Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Thu, 2 Nov 2023 16:11:59 -0400 Subject: [PATCH 08/16] fix(web): timeline alignment (#4808) --- web/src/lib/components/layouts/user-page-layout.svelte | 2 +- web/src/routes/(user)/trash/+page.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/layouts/user-page-layout.svelte b/web/src/lib/components/layouts/user-page-layout.svelte index 8f7212ae2..25e78ebc5 100644 --- a/web/src/lib/components/layouts/user-page-layout.svelte +++ b/web/src/lib/components/layouts/user-page-layout.svelte @@ -12,7 +12,7 @@ export let scrollbar = true; export let admin = false; - $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden pl-4'; + $: scrollbarClass = scrollbar ? 'immich-scrollbar p-4 pb-8' : 'scrollbar-hidden'; $: hasTitleClass = title ? 'top-16 h-[calc(100%-theme(spacing.16))]' : 'top-0 h-full'; diff --git a/web/src/routes/(user)/trash/+page.svelte b/web/src/routes/(user)/trash/+page.svelte index 5eaeb237d..5926f4b6a 100644 --- a/web/src/routes/(user)/trash/+page.svelte +++ b/web/src/routes/(user)/trash/+page.svelte @@ -87,7 +87,7 @@
-

+

Trashed items will be permanently deleted after {$serverConfig.trashDays} days.

Date: Fri, 3 Nov 2023 14:54:02 +0100 Subject: [PATCH 09/16] fix(web): scrollbar year label visibility (#4820) * fixes year label visibility * format fix --- .../scrollbar/scrollbar.svelte | 57 +++++++++++++------ 1 file changed, 39 insertions(+), 18 deletions(-) diff --git a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte index 24490329b..222654c59 100644 --- a/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte +++ b/web/src/lib/components/shared-components/scrollbar/scrollbar.svelte @@ -21,11 +21,27 @@ $: hoverY = height - windowHeight + clientY; $: scrollY = toScrollY(timelineY); - $: segments = $assetStore.buckets.map((bucket) => ({ - count: bucket.assets.length, - height: toScrollY(bucket.bucketHeight), - timeGroup: bucket.bucketDate, - })); + + class Segment { + public count; + public height; + public timeGroup; + + constructor({ count = 0, height = 0, timeGroup = '' }) { + this.count = count; + this.height = height; + this.timeGroup = timeGroup; + } + } + + $: segments = $assetStore.buckets.map( + (bucket) => + new Segment({ + count: bucket.assets.length, + height: toScrollY(bucket.bucketHeight), + timeGroup: bucket.bucketDate, + }), + ); const dispatch = createEventDispatcher<{ scrollTimeline: number }>(); const scrollTimeline = () => dispatch('scrollTimeline', toTimelineY(hoverY)); @@ -51,6 +67,23 @@ isAnimating = false; }); }; + + const prevYearSegmentHeight = (segments: Segment[], index: number) => { + var prevYear = null; + var height = 0; + for (var i = index; i >= 0; i--) { + const curr = segments[i]; + const currYear = fromLocalDateTime(curr.timeGroup).year; + if (prevYear && prevYear != currYear) { + break; + } + + height += curr.height; + prevYear = currYear; + } + + return height; + }; @@ -98,18 +131,6 @@ {@const label = `${date.toLocaleString({ month: 'short' })} ${year}`} {@const lastGroupYear = fromLocalDateTime(segments[index - 1]?.timeGroup).year} - - {@const canRenderYear = segments.slice(index + 1, index + 3).reduce((_, curr) => { - const nextGroupYear = fromLocalDateTime(curr.timeGroup).year; - - if (nextGroupYear !== year || curr.height < 1) { - return false; - } - - return true; - }, true)} -
(hoverLabel = label)} > - {#if lastGroupYear !== year && canRenderYear} + {#if lastGroupYear !== year && (index == 0 || prevYearSegmentHeight(segments, index - 1) > 16)}
Date: Fri, 3 Nov 2023 17:01:48 +0300 Subject: [PATCH 10/16] fix(web): unstacking issues (#4792) * Fix typo * Restore asset store consistency after unstacking * Fix aspect ratio after unstacking --- server/src/immich/api-v1/asset/asset-repository.ts | 4 +++- .../lib/components/asset-viewer/asset-viewer-nav-bar.svelte | 4 ++-- web/src/lib/components/asset-viewer/asset-viewer.svelte | 4 ++-- web/src/lib/stores/assets.store.ts | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/server/src/immich/api-v1/asset/asset-repository.ts b/server/src/immich/api-v1/asset/asset-repository.ts index e0e239f6d..9dac7e604 100644 --- a/server/src/immich/api-v1/asset/asset-repository.ts +++ b/server/src/immich/api-v1/asset/asset-repository.ts @@ -109,7 +109,9 @@ export class AssetRepository implements IAssetRepository { faces: { person: true, }, - stack: true, + stack: { + exifInfo: true, + }, }, // We are specifically asking for this asset. Return it even if it is soft deleted withDeleted: true, diff --git a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte index ebffe28d2..20d4820a7 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer-nav-bar.svelte @@ -32,7 +32,7 @@ export let showDownloadButton: boolean; export let showDetailButton: boolean; export let showSlideshow = false; - export let hasStackChildern = false; + export let hasStackChildren = false; $: isOwner = asset.ownerId === $page.data.user?.id; @@ -176,7 +176,7 @@ /> onMenuClick('asProfileImage')} text="As profile picture" /> - {#if hasStackChildern} + {#if hasStackChildren} onMenuClick('unstack')} text="Un-Stack" /> {/if} diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index c519346e8..83da35e70 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -549,7 +549,7 @@ } asset.stackCount = 0; asset.stack = []; - assetStore?.updateAsset(asset); + assetStore?.updateAsset(asset, true); dispatch('unstack'); notificationController.show({ type: NotificationType.Info, message: 'Un-stacked', timeout: 1500 }); @@ -575,7 +575,7 @@ showDownloadButton={shouldShowDownloadButton} showDetailButton={shouldShowDetailButton} showSlideshow={!!assetStore} - hasStackChildern={$stackAssetsStore.length > 0} + hasStackChildren={$stackAssetsStore.length > 0} on:goBack={closeViewer} on:showDetail={showDetailInfoHandler} on:download={() => downloadFile(asset)} diff --git a/web/src/lib/stores/assets.store.ts b/web/src/lib/stores/assets.store.ts index fa5c1ccdf..7488ddf6a 100644 --- a/web/src/lib/stores/assets.store.ts +++ b/web/src/lib/stores/assets.store.ts @@ -317,7 +317,7 @@ export class AssetStore { return bucket.assets[Math.floor(Math.random() * bucket.assets.length)] || null; } - updateAsset(_asset: AssetResponseDto) { + updateAsset(_asset: AssetResponseDto, recalculate = false) { const asset = this.assets.find((asset) => asset.id === _asset.id); if (!asset) { return; @@ -325,7 +325,7 @@ export class AssetStore { Object.assign(asset, _asset); - this.emit(false); + this.emit(recalculate); } removeAssets(ids: string[]) { From 5f43971ccf9e2d6e5059300c17b587dea14de671 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 3 Nov 2023 14:03:01 +0000 Subject: [PATCH 11/16] mobile: allow upload if local asset in selection (#4815) Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/modules/home/ui/control_bottom_app_bar.dart | 2 +- mobile/lib/modules/home/views/home_page.dart | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart index ec44aaef1..4f6e2706d 100644 --- a/mobile/lib/modules/home/ui/control_bottom_app_bar.dart +++ b/mobile/lib/modules/home/ui/control_bottom_app_bar.dart @@ -100,7 +100,7 @@ class ControlBottomAppBar extends ConsumerWidget { label: "control_bottom_app_bar_stack".tr(), onPressed: enabled ? onStack : null, ), - if (!hasRemote) + if (hasLocal) ControlBoxButton( iconData: Icons.backup_outlined, label: "Upload", diff --git a/mobile/lib/modules/home/views/home_page.dart b/mobile/lib/modules/home/views/home_page.dart index b1800e8e6..d41022a29 100644 --- a/mobile/lib/modules/home/views/home_page.dart +++ b/mobile/lib/modules/home/views/home_page.dart @@ -169,9 +169,10 @@ class HomePage extends HookConsumerWidget { processing.value = true; selectionEnabledHook.value = false; try { - ref - .read(manualUploadProvider.notifier) - .uploadAssets(context, selection.value); + ref.read(manualUploadProvider.notifier).uploadAssets( + context, + selection.value.where((a) => a.storage == AssetState.local), + ); } finally { processing.value = false; } From 81792a5342406551b85e0b30df7532f35f80b07e Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 3 Nov 2023 14:04:06 +0000 Subject: [PATCH 12/16] fix(mobile): immich app bar tap radius (#4816) * mobile: tool-tip for server url in app bar dialog * fix: Add Inkwell around the entire profile image * mobile: open documentation and github in browser --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../ui/app_bar_dialog/app_bar_dialog.dart | 2 + .../app_bar_dialog/app_bar_server_info.dart | 41 ++++++++++---- mobile/lib/shared/ui/immich_app_bar.dart | 56 +++++++++---------- 3 files changed, 57 insertions(+), 42 deletions(-) diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart index b17fce86d..ede113837 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart +++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_dialog.dart @@ -194,6 +194,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { Navigator.of(context).pop(); launchUrl( Uri.parse('https://immich.app'), + mode: LaunchMode.externalApplication, ); }, child: Text( @@ -213,6 +214,7 @@ class ImmichAppBarDialog extends HookConsumerWidget { Navigator.of(context).pop(); launchUrl( Uri.parse('https://github.com/immich-app/immich'), + mode: LaunchMode.externalApplication, ); }, child: Text( diff --git a/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart b/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart index 8ef3c09b5..fa4a73536 100644 --- a/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart +++ b/mobile/lib/shared/ui/app_bar_dialog/app_bar_server_info.dart @@ -182,19 +182,36 @@ class AppBarServerInfo extends HookConsumerWidget { 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, + child: Tooltip( + verticalOffset: 0, + decoration: BoxDecoration( + color: + Theme.of(context).primaryColor.withOpacity(0.9), + borderRadius: BorderRadius.circular(10), + ), + textStyle: TextStyle( + color: Theme.of(context).brightness == Brightness.dark + ? Colors.black + : Colors.white, + fontWeight: FontWeight.bold, + ), + message: getServerUrl() ?? '--', + preferBelow: false, + triggerMode: TooltipTriggerMode.tap, + 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, ), - textAlign: TextAlign.end, ), ), ), diff --git a/mobile/lib/shared/ui/immich_app_bar.dart b/mobile/lib/shared/ui/immich_app_bar.dart index ad8195354..3510931f6 100644 --- a/mobile/lib/shared/ui/immich_app_bar.dart +++ b/mobile/lib/shared/ui/immich_app_bar.dart @@ -31,7 +31,7 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { final isDarkMode = Theme.of(context).brightness == Brightness.dark; const widgetSize = 30.0; - buildProfilePhoto() { + buildProfileIndicator() { return InkWell( onTap: () => showDialog( context: context, @@ -39,37 +39,33 @@ class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget { 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, + child: 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: authState.profileImagePath.isEmpty || user == null + ? const Icon( + Icons.face_outlined, + size: widgetSize, + ) + : UserCircleAvatar( + radius: 15, + size: 27, + user: user, + ), ), - backgroundColor: Colors.transparent, - alignment: Alignment.bottomRight, - isLabelVisible: serverInfoState.isVersionMismatch, - offset: const Offset(2, 2), - child: buildProfilePhoto(), ); } From 33ce2b7bba7fa148d54a3af1cfbe40e1eba927c2 Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Fri, 3 Nov 2023 15:04:41 +0100 Subject: [PATCH 13/16] fix(mobile): shows asset datetime with original timezone (#4774) --- mobile/lib/main.dart | 3 + .../asset_viewer/ui/exif_bottom_sheet.dart | 35 +- mobile/lib/shared/models/exif_info.dart | 14 + mobile/lib/shared/models/exif_info.g.dart | 410 ++++++++++++++++-- mobile/pubspec.lock | 2 +- mobile/pubspec.yaml | 1 + 6 files changed, 413 insertions(+), 52 deletions(-) diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 3ad2f4b62..13eda9d6e 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -7,6 +7,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_displaymode/flutter_displaymode.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:timezone/data/latest.dart'; import 'package:immich_mobile/constants/locales.dart'; import 'package:immich_mobile/modules/backup/background_service/background.service.dart'; import 'package:immich_mobile/modules/backup/models/backup_album.model.dart'; @@ -77,6 +78,8 @@ Future initApp() async { log.severe('Catch all error: ${error.toString()} - $error', error, stack); return true; }; + + initializeTimeZones(); } Future loadDb() async { diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index df1c8ba6f..f194738a2 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -4,6 +4,7 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:timezone/timezone.dart'; import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart'; import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -26,12 +27,36 @@ class ExifBottomSheet extends HookConsumerWidget { exifInfo.latitude != 0 && exifInfo.longitude != 0; - String get formattedDateTime { - final fileCreatedAt = asset.fileCreatedAt.toLocal(); - final date = DateFormat.yMMMEd().format(fileCreatedAt); - final time = DateFormat.jm().format(fileCreatedAt); + String formatTimeZone(Duration d) => + "GMT${d.isNegative ? '-': '+'}${d.inHours.abs().toString().padLeft(2, '0')}:${d.inMinutes.abs().remainder(60).toString().padLeft(2, '0')}"; - return '$date • $time'; + String get formattedDateTime { + DateTime dt = asset.fileCreatedAt.toLocal(); + String? timeZone; + if (asset.exifInfo?.dateTimeOriginal != null) { + dt = asset.exifInfo!.dateTimeOriginal!; + if (asset.exifInfo?.timeZone != null) { + dt = dt.toUtc(); + try { + final location = getLocation(asset.exifInfo!.timeZone!); + dt = TZDateTime.from(dt, location); + } on LocationNotFoundException { + RegExp re = RegExp(r'^utc(?:([+-]\d{1,2})(?::(\d{2}))?)?$', caseSensitive: false); + final m = re.firstMatch(asset.exifInfo!.timeZone!); + if (m != null) { + final duration = Duration(hours: int.parse(m.group(1) ?? '0'), minutes: int.parse(m.group(2) ?? '0')); + dt = dt.add(duration); + timeZone = formatTimeZone(duration); + } + } + } + } + + final date = DateFormat.yMMMEd().format(dt); + final time = DateFormat.jm().format(dt); + timeZone ??= formatTimeZone(dt.timeZoneOffset); + + return '$date • $time $timeZone'; } Future _createCoordinatesUri(ExifInfo? exifInfo) async { diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart index 568e4ce13..a61fd2c28 100644 --- a/mobile/lib/shared/models/exif_info.dart +++ b/mobile/lib/shared/models/exif_info.dart @@ -8,6 +8,8 @@ part 'exif_info.g.dart'; class ExifInfo { Id? id; int? fileSize; + DateTime? dateTimeOriginal; + String? timeZone; String? make; String? model; String? lens; @@ -47,6 +49,8 @@ class ExifInfo { ExifInfo.fromDto(ExifResponseDto dto) : fileSize = dto.fileSizeInByte, + dateTimeOriginal = dto.dateTimeOriginal, + timeZone = dto.timeZone, make = dto.make, model = dto.model, lens = dto.lensModel, @@ -64,6 +68,8 @@ class ExifInfo { ExifInfo({ this.id, this.fileSize, + this.dateTimeOriginal, + this.timeZone, this.make, this.model, this.lens, @@ -82,6 +88,8 @@ class ExifInfo { ExifInfo copyWith({ Id? id, int? fileSize, + DateTime? dateTimeOriginal, + String? timeZone, String? make, String? model, String? lens, @@ -99,6 +107,8 @@ class ExifInfo { ExifInfo( id: id ?? this.id, fileSize: fileSize ?? this.fileSize, + dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal, + timeZone: timeZone ?? this.timeZone, make: make ?? this.make, model: model ?? this.model, lens: lens ?? this.lens, @@ -119,6 +129,8 @@ class ExifInfo { if (other is! ExifInfo) return false; return id == other.id && fileSize == other.fileSize && + dateTimeOriginal == other.dateTimeOriginal && + timeZone == other.timeZone && make == other.make && model == other.model && lens == other.lens && @@ -139,6 +151,8 @@ class ExifInfo { int get hashCode => id.hashCode ^ fileSize.hashCode ^ + dateTimeOriginal.hashCode ^ + timeZone.hashCode ^ make.hashCode ^ model.hashCode ^ lens.hashCode ^ diff --git a/mobile/lib/shared/models/exif_info.g.dart b/mobile/lib/shared/models/exif_info.g.dart index 9122942bd..138e386c7 100644 --- a/mobile/lib/shared/models/exif_info.g.dart +++ b/mobile/lib/shared/models/exif_info.g.dart @@ -27,65 +27,75 @@ const ExifInfoSchema = CollectionSchema( name: r'country', type: IsarType.string, ), - r'description': PropertySchema( + r'dateTimeOriginal': PropertySchema( id: 2, + name: r'dateTimeOriginal', + type: IsarType.dateTime, + ), + r'description': PropertySchema( + id: 3, name: r'description', type: IsarType.string, ), r'exposureSeconds': PropertySchema( - id: 3, + id: 4, name: r'exposureSeconds', type: IsarType.float, ), r'f': PropertySchema( - id: 4, + id: 5, name: r'f', type: IsarType.float, ), r'fileSize': PropertySchema( - id: 5, + id: 6, name: r'fileSize', type: IsarType.long, ), r'iso': PropertySchema( - id: 6, + id: 7, name: r'iso', type: IsarType.int, ), r'lat': PropertySchema( - id: 7, + id: 8, name: r'lat', type: IsarType.float, ), r'lens': PropertySchema( - id: 8, + id: 9, name: r'lens', type: IsarType.string, ), r'long': PropertySchema( - id: 9, + id: 10, name: r'long', type: IsarType.float, ), r'make': PropertySchema( - id: 10, + id: 11, name: r'make', type: IsarType.string, ), r'mm': PropertySchema( - id: 11, + id: 12, name: r'mm', type: IsarType.float, ), r'model': PropertySchema( - id: 12, + id: 13, name: r'model', type: IsarType.string, ), r'state': PropertySchema( - id: 13, + id: 14, name: r'state', type: IsarType.string, + ), + r'timeZone': PropertySchema( + id: 15, + name: r'timeZone', + type: IsarType.string, ) }, estimateSize: _exifInfoEstimateSize, @@ -150,6 +160,12 @@ int _exifInfoEstimateSize( bytesCount += 3 + value.length * 3; } } + { + final value = object.timeZone; + if (value != null) { + bytesCount += 3 + value.length * 3; + } + } return bytesCount; } @@ -161,18 +177,20 @@ void _exifInfoSerialize( ) { writer.writeString(offsets[0], object.city); writer.writeString(offsets[1], object.country); - writer.writeString(offsets[2], object.description); - writer.writeFloat(offsets[3], object.exposureSeconds); - writer.writeFloat(offsets[4], object.f); - writer.writeLong(offsets[5], object.fileSize); - writer.writeInt(offsets[6], object.iso); - writer.writeFloat(offsets[7], object.lat); - writer.writeString(offsets[8], object.lens); - writer.writeFloat(offsets[9], object.long); - writer.writeString(offsets[10], object.make); - writer.writeFloat(offsets[11], object.mm); - writer.writeString(offsets[12], object.model); - writer.writeString(offsets[13], object.state); + writer.writeDateTime(offsets[2], object.dateTimeOriginal); + writer.writeString(offsets[3], object.description); + writer.writeFloat(offsets[4], object.exposureSeconds); + writer.writeFloat(offsets[5], object.f); + writer.writeLong(offsets[6], object.fileSize); + writer.writeInt(offsets[7], object.iso); + writer.writeFloat(offsets[8], object.lat); + writer.writeString(offsets[9], object.lens); + writer.writeFloat(offsets[10], object.long); + writer.writeString(offsets[11], object.make); + writer.writeFloat(offsets[12], object.mm); + writer.writeString(offsets[13], object.model); + writer.writeString(offsets[14], object.state); + writer.writeString(offsets[15], object.timeZone); } ExifInfo _exifInfoDeserialize( @@ -184,19 +202,21 @@ ExifInfo _exifInfoDeserialize( final object = ExifInfo( city: reader.readStringOrNull(offsets[0]), country: reader.readStringOrNull(offsets[1]), - description: reader.readStringOrNull(offsets[2]), - exposureSeconds: reader.readFloatOrNull(offsets[3]), - f: reader.readFloatOrNull(offsets[4]), - fileSize: reader.readLongOrNull(offsets[5]), + dateTimeOriginal: reader.readDateTimeOrNull(offsets[2]), + description: reader.readStringOrNull(offsets[3]), + exposureSeconds: reader.readFloatOrNull(offsets[4]), + f: reader.readFloatOrNull(offsets[5]), + fileSize: reader.readLongOrNull(offsets[6]), id: id, - iso: reader.readIntOrNull(offsets[6]), - lat: reader.readFloatOrNull(offsets[7]), - lens: reader.readStringOrNull(offsets[8]), - long: reader.readFloatOrNull(offsets[9]), - make: reader.readStringOrNull(offsets[10]), - mm: reader.readFloatOrNull(offsets[11]), - model: reader.readStringOrNull(offsets[12]), - state: reader.readStringOrNull(offsets[13]), + iso: reader.readIntOrNull(offsets[7]), + lat: reader.readFloatOrNull(offsets[8]), + lens: reader.readStringOrNull(offsets[9]), + long: reader.readFloatOrNull(offsets[10]), + make: reader.readStringOrNull(offsets[11]), + mm: reader.readFloatOrNull(offsets[12]), + model: reader.readStringOrNull(offsets[13]), + state: reader.readStringOrNull(offsets[14]), + timeZone: reader.readStringOrNull(offsets[15]), ); return object; } @@ -213,29 +233,33 @@ P _exifInfoDeserializeProp

( case 1: return (reader.readStringOrNull(offset)) as P; case 2: - return (reader.readStringOrNull(offset)) as P; + return (reader.readDateTimeOrNull(offset)) as P; case 3: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 4: return (reader.readFloatOrNull(offset)) as P; case 5: - return (reader.readLongOrNull(offset)) as P; + return (reader.readFloatOrNull(offset)) as P; case 6: - return (reader.readIntOrNull(offset)) as P; + return (reader.readLongOrNull(offset)) as P; case 7: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readIntOrNull(offset)) as P; case 8: - return (reader.readStringOrNull(offset)) as P; + return (reader.readFloatOrNull(offset)) as P; case 9: - return (reader.readFloatOrNull(offset)) as P; + return (reader.readStringOrNull(offset)) as P; case 10: - return (reader.readStringOrNull(offset)) as P; - case 11: return (reader.readFloatOrNull(offset)) as P; - case 12: + case 11: return (reader.readStringOrNull(offset)) as P; + case 12: + return (reader.readFloatOrNull(offset)) as P; case 13: return (reader.readStringOrNull(offset)) as P; + case 14: + return (reader.readStringOrNull(offset)) as P; + case 15: + return (reader.readStringOrNull(offset)) as P; default: throw IsarError('Unknown property with id $propertyId'); } @@ -622,6 +646,80 @@ extension ExifInfoQueryFilter }); } + QueryBuilder + dateTimeOriginalIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'dateTimeOriginal', + )); + }); + } + + QueryBuilder + dateTimeOriginalIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'dateTimeOriginal', + )); + }); + } + + QueryBuilder + dateTimeOriginalEqualTo(DateTime? value) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalGreaterThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalLessThan( + DateTime? value, { + bool include = false, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'dateTimeOriginal', + value: value, + )); + }); + } + + QueryBuilder + dateTimeOriginalBetween( + DateTime? lower, + DateTime? upper, { + bool includeLower = true, + bool includeUpper = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'dateTimeOriginal', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + )); + }); + } + QueryBuilder descriptionIsNull() { return QueryBuilder.apply(this, (query) { return query.addFilterCondition(const FilterCondition.isNull( @@ -1956,6 +2054,152 @@ extension ExifInfoQueryFilter )); }); } + + QueryBuilder timeZoneIsNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNull( + property: r'timeZone', + )); + }); + } + + QueryBuilder timeZoneIsNotNull() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(const FilterCondition.isNotNull( + property: r'timeZone', + )); + }); + } + + QueryBuilder timeZoneEqualTo( + String? value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneGreaterThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + include: include, + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneLessThan( + String? value, { + bool include = false, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.lessThan( + include: include, + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneBetween( + String? lower, + String? upper, { + bool includeLower = true, + bool includeUpper = true, + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.between( + property: r'timeZone', + lower: lower, + includeLower: includeLower, + upper: upper, + includeUpper: includeUpper, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneStartsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.startsWith( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneEndsWith( + String value, { + bool caseSensitive = true, + }) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.endsWith( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneContains( + String value, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.contains( + property: r'timeZone', + value: value, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneMatches( + String pattern, + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.matches( + property: r'timeZone', + wildcard: pattern, + caseSensitive: caseSensitive, + )); + }); + } + + QueryBuilder timeZoneIsEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.equalTo( + property: r'timeZone', + value: '', + )); + }); + } + + QueryBuilder timeZoneIsNotEmpty() { + return QueryBuilder.apply(this, (query) { + return query.addFilterCondition(FilterCondition.greaterThan( + property: r'timeZone', + value: '', + )); + }); + } } extension ExifInfoQueryObject @@ -1989,6 +2233,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { }); } + QueryBuilder sortByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.asc); + }); + } + + QueryBuilder sortByDateTimeOriginalDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.desc); + }); + } + QueryBuilder sortByDescription() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'description', Sort.asc); @@ -2132,6 +2388,18 @@ extension ExifInfoQuerySortBy on QueryBuilder { return query.addSortBy(r'state', Sort.desc); }); } + + QueryBuilder sortByTimeZone() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.asc); + }); + } + + QueryBuilder sortByTimeZoneDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.desc); + }); + } } extension ExifInfoQuerySortThenBy @@ -2160,6 +2428,18 @@ extension ExifInfoQuerySortThenBy }); } + QueryBuilder thenByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.asc); + }); + } + + QueryBuilder thenByDateTimeOriginalDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'dateTimeOriginal', Sort.desc); + }); + } + QueryBuilder thenByDescription() { return QueryBuilder.apply(this, (query) { return query.addSortBy(r'description', Sort.asc); @@ -2315,6 +2595,18 @@ extension ExifInfoQuerySortThenBy return query.addSortBy(r'state', Sort.desc); }); } + + QueryBuilder thenByTimeZone() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.asc); + }); + } + + QueryBuilder thenByTimeZoneDesc() { + return QueryBuilder.apply(this, (query) { + return query.addSortBy(r'timeZone', Sort.desc); + }); + } } extension ExifInfoQueryWhereDistinct @@ -2333,6 +2625,12 @@ extension ExifInfoQueryWhereDistinct }); } + QueryBuilder distinctByDateTimeOriginal() { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'dateTimeOriginal'); + }); + } + QueryBuilder distinctByDescription( {bool caseSensitive = true}) { return QueryBuilder.apply(this, (query) { @@ -2409,6 +2707,13 @@ extension ExifInfoQueryWhereDistinct return query.addDistinctBy(r'state', caseSensitive: caseSensitive); }); } + + QueryBuilder distinctByTimeZone( + {bool caseSensitive = true}) { + return QueryBuilder.apply(this, (query) { + return query.addDistinctBy(r'timeZone', caseSensitive: caseSensitive); + }); + } } extension ExifInfoQueryProperty @@ -2431,6 +2736,13 @@ extension ExifInfoQueryProperty }); } + QueryBuilder + dateTimeOriginalProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'dateTimeOriginal'); + }); + } + QueryBuilder descriptionProperty() { return QueryBuilder.apply(this, (query) { return query.addPropertyName(r'description'); @@ -2502,4 +2814,10 @@ extension ExifInfoQueryProperty return query.addPropertyName(r'state'); }); } + + QueryBuilder timeZoneProperty() { + return QueryBuilder.apply(this, (query) { + return query.addPropertyName(r'timeZone'); + }); + } } diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 73bfd11b0..3fc34f62e 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1401,7 +1401,7 @@ packages: source: hosted version: "2.1.3" timezone: - dependency: transitive + dependency: "direct main" description: name: timezone sha256: "1cfd8ddc2d1cfd836bc93e67b9be88c3adaeca6f40a00ca999104c30693cdca0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 63c6f312f..6d5aa735d 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -52,6 +52,7 @@ dependencies: crypto: ^3.0.3 # TODO remove once native crypto is used on iOS wakelock_plus: ^1.1.1 flutter_local_notifications: ^15.1.0+1 + timezone: ^0.9.2 openapi: path: openapi From 621eef0edc66ac742e9ddf001465ae5831e47919 Mon Sep 17 00:00:00 2001 From: waclaw66 Date: Fri, 3 Nov 2023 22:00:55 +0100 Subject: [PATCH 14/16] feat(mobile): share assets from album (#4821) * share from album * fix case * enhance conditional array items --- mobile/assets/i18n/en-US.json | 1 + .../modules/album/ui/album_viewer_appbar.dart | 69 +++++++++++++++---- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/mobile/assets/i18n/en-US.json b/mobile/assets/i18n/en-US.json index 4a77aac1c..69700269c 100644 --- a/mobile/assets/i18n/en-US.json +++ b/mobile/assets/i18n/en-US.json @@ -23,6 +23,7 @@ "album_viewer_appbar_share_err_title": "Failed to change album title", "album_viewer_appbar_share_leave": "Leave album", "album_viewer_appbar_share_remove": "Remove from album", + "album_viewer_appbar_share_to": "Share To", "album_viewer_page_share_add_users": "Add users", "all_people_page_title": "People", "all_videos_page_title": "Videos", diff --git a/mobile/lib/modules/album/ui/album_viewer_appbar.dart b/mobile/lib/modules/album/ui/album_viewer_appbar.dart index f369a35d1..221603ed9 100644 --- a/mobile/lib/modules/album/ui/album_viewer_appbar.dart +++ b/mobile/lib/modules/album/ui/album_viewer_appbar.dart @@ -7,6 +7,8 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_detail.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; +import 'package:immich_mobile/shared/ui/share_dialog.dart'; +import 'package:immich_mobile/shared/services/share.service.dart'; import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/asset.dart'; @@ -160,40 +162,77 @@ class AlbumViewerAppbar extends HookConsumerWidget ImmichLoadingOverlayController.appLoader.hide(); } - buildBottomSheetActionButton() { + void handleShareAssets( + WidgetRef ref, + BuildContext context, + Set selection, + ) { + showDialog( + context: context, + builder: (BuildContext buildContext) { + ref.watch(shareServiceProvider).shareAssets(selection.toList()).then( + (bool status) { + if (!status) { + ImmichToast.show( + context: context, + msg: 'image_viewer_page_state_provider_share_error'.tr(), + toastType: ToastType.error, + gravity: ToastGravity.BOTTOM, + ); + } + Navigator.of(buildContext).pop(); + }, + ); + return const ShareDialog(); + }, + barrierDismissible: false, + ); + } + + void onShareAssetsTo() async { + ImmichLoadingOverlayController.appLoader.show(); + handleShareAssets(ref, context, selected); + ImmichLoadingOverlayController.appLoader.hide(); + } + + buildBottomSheetActions() { if (selected.isNotEmpty) { - if (album.ownerId == userId) { - return ListTile( + return [ + ListTile( + leading: const Icon(Icons.ios_share_rounded), + title: const Text( + 'album_viewer_appbar_share_to', + style: TextStyle(fontWeight: FontWeight.bold), + ).tr(), + onTap: () => onShareAssetsTo(), + ), + album.ownerId == userId ? ListTile( leading: const Icon(Icons.delete_sweep_rounded), title: const Text( 'album_viewer_appbar_share_remove', style: TextStyle(fontWeight: FontWeight.bold), ).tr(), onTap: () => onRemoveFromAlbumPressed(), - ); - } else { - return const SizedBox(); - } + ) : const SizedBox(), + ]; } else { - if (album.ownerId == userId) { - return ListTile( + return [ + album.ownerId == userId ? ListTile( leading: const Icon(Icons.delete_forever_rounded), title: const Text( 'album_viewer_appbar_share_delete', style: TextStyle(fontWeight: FontWeight.bold), ).tr(), onTap: () => onDeleteAlbumPressed(), - ); - } else { - return ListTile( + ) : ListTile( leading: const Icon(Icons.person_remove_rounded), title: const Text( 'album_viewer_appbar_share_leave', style: TextStyle(fontWeight: FontWeight.bold), ).tr(), onTap: () => onLeaveAlbumPressed(), - ); - } + ), + ]; } } @@ -257,7 +296,7 @@ class AlbumViewerAppbar extends HookConsumerWidget child: Column( mainAxisSize: MainAxisSize.min, children: [ - buildBottomSheetActionButton(), + ...buildBottomSheetActions(), if (selected.isEmpty && onAddPhotos != null) ...commonActions, if (selected.isEmpty && onAddPhotos != null && From 330f4cadda1c6b8e609cbaa4e24ab3d2a15295ea Mon Sep 17 00:00:00 2001 From: Jesbin <52061387+jesb1n@users.noreply.github.com> Date: Sat, 4 Nov 2023 02:31:17 +0530 Subject: [PATCH 15/16] docs: changes to docker compose command. (#4828) --- docs/docs/administration/backup-and-restore.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index bfea5aa3c..da9e271b5 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -17,13 +17,13 @@ docker exec -t immich_postgres pg_dumpall -c -U postgres | gzip > "/path/to/back ``` ```bash title='Restore' -docker-compose down -v # CAUTION! Deletes all Immich data to start from scratch. -docker-compose pull # Update to latest version of Immich (if desired) -docker-compose create # Create Docker containers for Immich apps without running them. +docker compose down -v # CAUTION! Deletes all Immich data to start from scratch. +docker compose pull # Update to latest version of Immich (if desired) +docker compose create # Create Docker containers for Immich apps without running them. docker start immich_postgres # Start Postgres server sleep 10 # Wait for Postgres server to start up gunzip < "/path/to/backup/dump.sql.gz" | docker exec -i immich_postgres psql -U postgres -d immich # Restore Backup -docker-compose up -d # Start remainder of Immich apps +docker compose up -d # Start remainder of Immich apps ``` Note that for the database restore to proceed properly, it requires a completely fresh install (i.e. the Immich server has never run since creating the Docker containers). If the Immich app has run, Postgres conflicts may be encountered upon database restoration (relation already exists, violated foreign key constraints, multiple primary keys, etc.). From e1e45f3f32a6270c64fa9392906bc0668d6bea4f Mon Sep 17 00:00:00 2001 From: martin <74269598+martabal@users.noreply.github.com> Date: Fri, 3 Nov 2023 22:02:05 +0100 Subject: [PATCH 16/16] fix(web): show one face for the same person in the detail panel (#4822) --- .../src/domain/asset/response-dto/asset-response.dto.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/server/src/domain/asset/response-dto/asset-response.dto.ts b/server/src/domain/asset/response-dto/asset-response.dto.ts index 0f5e01320..bacd4bfe6 100644 --- a/server/src/domain/asset/response-dto/asset-response.dto.ts +++ b/server/src/domain/asset/response-dto/asset-response.dto.ts @@ -98,7 +98,14 @@ export function mapAsset(entity: AssetEntity, options: AssetMapOptions = {}): As tags: entity.tags?.map(mapTag), people: entity.faces ?.map(mapFace) - .filter((person): person is PersonResponseDto => person !== null && !person.isHidden), + .filter((person): person is PersonResponseDto => person !== null && !person.isHidden) + .reduce((people, person) => { + const existingPerson = people.find((p) => p.id === person.id); + if (!existingPerson) { + people.push(person); + } + return people; + }, [] as PersonResponseDto[]), checksum: entity.checksum.toString('base64'), stackParentId: entity.stackParentId, stack: withStack ? entity.stack?.map((a) => mapAsset(a, { stripMetadata })) ?? undefined : undefined,