From ac3eea80d2724ddeb5b32780547ad4f77e148a1b Mon Sep 17 00:00:00 2001 From: Yaros Date: Sun, 12 Apr 2026 13:36:49 +0200 Subject: [PATCH] fix(server/web): shared albums in map sidebar --- mobile/openapi/lib/api/timeline_api.dart | 30 ++++++-- open-api/immich-openapi-specs.json | 18 +++++ open-api/typescript-sdk/src/fetch-client.ts | 8 ++- server/src/dtos/time-bucket.dto.ts | 6 ++ server/src/repositories/asset.repository.ts | 71 ++++++++++++++++++- .../map/MapTimelinePanel.svelte | 1 + 6 files changed, 124 insertions(+), 10 deletions(-) diff --git a/mobile/openapi/lib/api/timeline_api.dart b/mobile/openapi/lib/api/timeline_api.dart index f82c362ff7..ccb5930e3c 100644 --- a/mobile/openapi/lib/api/timeline_api.dart +++ b/mobile/openapi/lib/api/timeline_api.dart @@ -64,9 +64,12 @@ class TimelineApi { /// * [bool] withPartners: /// Include assets shared by partners /// + /// * [bool] withSharedAlbums: + /// Include assets from shared albums + /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketWithHttpInfo(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/bucket'; @@ -117,6 +120,9 @@ class TimelineApi { if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } + if (withSharedAlbums != null) { + queryParams.addAll(_queryParams('', 'withSharedAlbums', withSharedAlbums)); + } if (withStacked != null) { queryParams.addAll(_queryParams('', 'withStacked', withStacked)); } @@ -181,10 +187,13 @@ class TimelineApi { /// * [bool] withPartners: /// Include assets shared by partners /// + /// * [bool] withSharedAlbums: + /// Include assets from shared albums + /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future getTimeBucket(String timeBucket, { String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async { + final response = await getTimeBucketWithHttpInfo(timeBucket, albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withSharedAlbums: withSharedAlbums, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } @@ -243,9 +252,12 @@ class TimelineApi { /// * [bool] withPartners: /// Include assets shared by partners /// + /// * [bool] withSharedAlbums: + /// Include assets from shared albums + /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { + Future getTimeBucketsWithHttpInfo({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async { // ignore: prefer_const_declarations final apiPath = r'/timeline/buckets'; @@ -295,6 +307,9 @@ class TimelineApi { if (withPartners != null) { queryParams.addAll(_queryParams('', 'withPartners', withPartners)); } + if (withSharedAlbums != null) { + queryParams.addAll(_queryParams('', 'withSharedAlbums', withSharedAlbums)); + } if (withStacked != null) { queryParams.addAll(_queryParams('', 'withStacked', withStacked)); } @@ -356,10 +371,13 @@ class TimelineApi { /// * [bool] withPartners: /// Include assets shared by partners /// + /// * [bool] withSharedAlbums: + /// Include assets from shared albums + /// /// * [bool] withStacked: /// Include stacked assets in the response. When true, only primary assets from stacks are returned. - Future?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withStacked, }) async { - final response = await getTimeBucketsWithHttpInfo( albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withStacked: withStacked, ); + Future?> getTimeBuckets({ String? albumId, String? bbox, bool? isFavorite, bool? isTrashed, String? key, AssetOrder? order, String? personId, String? slug, String? tagId, String? userId, AssetVisibility? visibility, bool? withCoordinates, bool? withPartners, bool? withSharedAlbums, bool? withStacked, }) async { + final response = await getTimeBucketsWithHttpInfo( albumId: albumId, bbox: bbox, isFavorite: isFavorite, isTrashed: isTrashed, key: key, order: order, personId: personId, slug: slug, tagId: tagId, userId: userId, visibility: visibility, withCoordinates: withCoordinates, withPartners: withPartners, withSharedAlbums: withSharedAlbums, withStacked: withStacked, ); if (response.statusCode >= HttpStatus.badRequest) { throw ApiException(response.statusCode, await _decodeBodyBytes(response)); } diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 36eb3aa17e..016599d2e1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -13656,6 +13656,15 @@ "type": "boolean" } }, + { + "name": "withSharedAlbums", + "required": false, + "in": "query", + "description": "Include assets from shared albums", + "schema": { + "type": "boolean" + } + }, { "name": "withStacked", "required": false, @@ -13832,6 +13841,15 @@ "type": "boolean" } }, + { + "name": "withSharedAlbums", + "required": false, + "in": "query", + "description": "Include assets from shared albums", + "schema": { + "type": "boolean" + } + }, { "name": "withStacked", "required": false, diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index be871c0309..f3846a2067 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -6446,7 +6446,7 @@ export function tagAssets({ id, bulkIdsDto }: { /** * Get time bucket */ -export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, timeBucket, userId, visibility, withCoordinates, withPartners, withSharedAlbums, withStacked }: { albumId?: string; bbox?: string; isFavorite?: boolean; @@ -6461,6 +6461,7 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order visibility?: AssetVisibility; withCoordinates?: boolean; withPartners?: boolean; + withSharedAlbums?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -6481,6 +6482,7 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order visibility, withCoordinates, withPartners, + withSharedAlbums, withStacked }))}`, { ...opts @@ -6489,7 +6491,7 @@ export function getTimeBucket({ albumId, bbox, isFavorite, isTrashed, key, order /** * Get time buckets */ -export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withStacked }: { +export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, order, personId, slug, tagId, userId, visibility, withCoordinates, withPartners, withSharedAlbums, withStacked }: { albumId?: string; bbox?: string; isFavorite?: boolean; @@ -6503,6 +6505,7 @@ export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, orde visibility?: AssetVisibility; withCoordinates?: boolean; withPartners?: boolean; + withSharedAlbums?: boolean; withStacked?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ @@ -6522,6 +6525,7 @@ export function getTimeBuckets({ albumId, bbox, isFavorite, isTrashed, key, orde visibility, withCoordinates, withPartners, + withSharedAlbums, withStacked }))}`, { ...opts diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index 9ea9dc49ae..dc79005f8d 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -63,6 +63,12 @@ export class TimeBucketDto { @ValidateBBox({ optional: true }) bbox?: BBoxDto; + + @ValidateBoolean({ + optional: true, + description: 'Include assets from shared albums', + }) + withSharedAlbums?: boolean; } export class TimeBucketAssetDto extends TimeBucketDto { diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 5876b934e5..e5ee22c804 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -83,6 +83,7 @@ interface AssetBuilderOptions { assetType?: AssetType; visibility?: AssetVisibility; withCoordinates?: boolean; + withSharedAlbums?: boolean; bbox?: BoundingBox; } @@ -732,7 +733,40 @@ export class AssetRepository { ) .where((eb) => eb.or([eb('asset.stackId', 'is', null), eb(eb.table('stack'), 'is not', null)])), ) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.userIds, (qb) => + qb.where((eb) => + eb.or([ + eb('asset.ownerId', '=', anyUuid(options.userIds!)), + ...(options.withSharedAlbums + ? [ + eb.exists( + eb + .selectFrom('album_asset') + .innerJoin('album', 'album.id', 'album_asset.albumId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where((eb) => + eb.or([ + eb('album.ownerId', '=', anyUuid(options.userIds!)), + eb.exists( + eb + .selectFrom('album_user') + .whereRef('album_user.albumId', '=', 'album.id') + .where('album_user.userId', '=', anyUuid(options.userIds!)), + ), + eb.exists( + eb + .selectFrom('shared_link') + .whereRef('shared_link.albumId', '=', 'album.id') + .where('shared_link.userId', '=', anyUuid(options.userIds!)), + ), + ]), + ), + ), + ] + : []), + ]), + ), + ) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(!!options.assetType, (qb) => qb.where('asset.type', '=', options.assetType!)) .$if(options.isDuplicate !== undefined, (qb) => @@ -816,7 +850,40 @@ export class AssetRepository { ), ) .$if(!!options.personId, (qb) => hasPeople(qb, [options.personId!])) - .$if(!!options.userIds, (qb) => qb.where('asset.ownerId', '=', anyUuid(options.userIds!))) + .$if(!!options.userIds, (qb) => + qb.where((eb) => + eb.or([ + eb('asset.ownerId', '=', anyUuid(options.userIds!)), + ...(options.withSharedAlbums + ? [ + eb.exists( + eb + .selectFrom('album_asset') + .innerJoin('album', 'album.id', 'album_asset.albumId') + .whereRef('album_asset.assetId', '=', 'asset.id') + .where((eb) => + eb.or([ + eb('album.ownerId', '=', anyUuid(options.userIds!)), + eb.exists( + eb + .selectFrom('album_user') + .whereRef('album_user.albumId', '=', 'album.id') + .where('album_user.userId', '=', anyUuid(options.userIds!)), + ), + eb.exists( + eb + .selectFrom('shared_link') + .whereRef('shared_link.albumId', '=', 'album.id') + .where('shared_link.userId', '=', anyUuid(options.userIds!)), + ), + ]), + ), + ), + ] + : []), + ]), + ), + ) .$if(options.isFavorite !== undefined, (qb) => qb.where('asset.isFavorite', '=', options.isFavorite!)) .$if(!!options.withStacked, (qb) => qb diff --git a/web/src/lib/components/shared-components/map/MapTimelinePanel.svelte b/web/src/lib/components/shared-components/map/MapTimelinePanel.svelte index dba6128ab1..6e4c10cf5f 100644 --- a/web/src/lib/components/shared-components/map/MapTimelinePanel.svelte +++ b/web/src/lib/components/shared-components/map/MapTimelinePanel.svelte @@ -85,6 +85,7 @@ visibility: $mapSettings.includeArchived ? undefined : AssetVisibility.Timeline, isFavorite: $mapSettings.onlyFavorites || undefined, withPartners: $mapSettings.withPartners || undefined, + withSharedAlbums: $mapSettings.withSharedAlbums || undefined, assetFilter: selectedClusterIds, });