From 48927f5fb90d06dccb066f103c67853f84b3606e Mon Sep 17 00:00:00 2001 From: Andreas Gerstmayr Date: Mon, 13 May 2024 15:28:57 +0200 Subject: [PATCH] feat(server, web): include pictures of shared albums on map (#7439) * feat(server, web): include pictures of shared albums on map * run prettier * re-create api clients * implement suggestions from code review * shared from partner -> shared from partners * rename to 'include shared partner assets' * chore: fix tsc error in server and prettier in web * fix: include assets shared via owner albums --------- Co-authored-by: Zack Pollard Co-authored-by: Jason Rasmussen --- mobile/openapi/doc/AssetApi.md | Bin 39301 -> 39428 bytes mobile/openapi/lib/api/asset_api.dart | Bin 36139 -> 36425 bytes mobile/openapi/test/asset_api_test.dart | Bin 3827 -> 3850 bytes open-api/immich-openapi-specs.json | 8 +++++ open-api/typescript-sdk/src/fetch-client.ts | 6 ++-- server/src/dtos/search.dto.ts | 3 ++ server/src/interfaces/asset.interface.ts | 2 +- server/src/repositories/asset.repository.ts | 32 +++++++++++------- server/src/services/asset.service.spec.ts | 5 +++ server/src/services/asset.service.ts | 16 ++++++++- .../map-page/map-settings-modal.svelte | 7 +++- web/src/lib/stores/preferences.store.ts | 2 ++ .../[[assetId=id]]/+page.svelte | 3 +- 13 files changed, 66 insertions(+), 18 deletions(-) diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index d710ef926ad2af51e77d0c7ecb5aef68f7b1fb23..a1491c79a2d812d8408ae11e92c9bf8ef7eb1967 100644 GIT binary patch delta 126 zcmZqO%+#`lX~PRIAsvPC%#w`YjKreU6vv#T(%jxvrrc!1d|<4?~{5yDwAMPRFiR8RI?gUc>$CDVH&gEQYJ^8 CC=xdS diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 65150468694b916d49248a1fae56cc55bdb698ac..0363ee73b05a1d2bba1a814d366b15ab393fef0d 100644 GIT binary patch delta 260 zcmZ2Ii|OPXrVS4qC;#B#oopb+Hu-`h3r}!HVo_>}V@^_OZt>)cmJ*Y{IChC8<>%+v zE0kxJWS~ed5cl7F+KH1{LIDUe(-br?R2+~nS4J09*TFDpvz&<}<77T}(aAsDW+6L= p8_B-Oz3!`Ux#Xd)_T&VQbxP=VSz%hWu)covgKL79_uNTrkG XEh&>)Nv^XoF5Ur?4JmrFgiOUjXciht diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index 0a278daa329cb7d539257094970151273361847c..aa6fa6c2785c391f03e19b2339b98d11debd7b1e 100644 GIT binary patch delta 40 vcmew?+a7&J(NvA3zQP delta 16 XcmeB@`z*VGpL22nyUb=M&J(NvGA{*k diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index eea90fb1c9..8cfa31c3c6 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1386,6 +1386,14 @@ "schema": { "type": "boolean" } + }, + { + "name": "withSharedAlbums", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } } ], "responses": { diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 23b3b00bed..2db110fa15 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -1442,12 +1442,13 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } -export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners }: { +export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, isFavorite, withPartners, withSharedAlbums }: { fileCreatedAfter?: string; fileCreatedBefore?: string; isArchived?: boolean; isFavorite?: boolean; withPartners?: boolean; + withSharedAlbums?: boolean; }, opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -1457,7 +1458,8 @@ export function getMapMarkers({ fileCreatedAfter, fileCreatedBefore, isArchived, fileCreatedBefore, isArchived, isFavorite, - withPartners + withPartners, + withSharedAlbums }))}`, { ...opts })); diff --git a/server/src/dtos/search.dto.ts b/server/src/dtos/search.dto.ts index 31d4195e7a..4d05b9f3aa 100644 --- a/server/src/dtos/search.dto.ts +++ b/server/src/dtos/search.dto.ts @@ -317,6 +317,9 @@ export class MapMarkerDto { @ValidateBoolean({ optional: true }) withPartners?: boolean; + + @ValidateBoolean({ optional: true }) + withSharedAlbums?: boolean; } export class MemoryLaneDto { diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 2c8f077cfb..79d90dde67 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -183,7 +183,7 @@ export interface IAssetRepository { softDeleteAll(ids: string[]): Promise; restoreAll(ids: string[]): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): Promise; - getMapMarkers(ownerIds: string[], options?: MapMarkerSearchOptions): Promise; + getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; getStatistics(ownerId: string, options: AssetStatsOptions): Promise; getTimeBuckets(options: TimeBucketOptions): Promise; getTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise; diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 7c359aa895..f9ed1c468c 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -490,9 +490,24 @@ export class AssetRepository implements IAssetRepository { }); } - async getMapMarkers(ownerIds: string[], options: MapMarkerSearchOptions = {}): Promise { + async getMapMarkers( + ownerIds: string[], + albumIds: string[], + options: MapMarkerSearchOptions = {}, + ): Promise { const { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore } = options; + const where = { + isVisible: true, + isArchived, + exifInfo: { + latitude: Not(IsNull()), + longitude: Not(IsNull()), + }, + isFavorite, + fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore), + }; + const assets = await this.repository.find({ select: { id: true, @@ -504,17 +519,10 @@ export class AssetRepository implements IAssetRepository { longitude: true, }, }, - where: { - ownerId: In([...ownerIds]), - isVisible: true, - isArchived, - exifInfo: { - latitude: Not(IsNull()), - longitude: Not(IsNull()), - }, - isFavorite, - fileCreatedAt: OptionalBetween(fileCreatedAfter, fileCreatedBefore), - }, + where: [ + { ...where, ownerId: In([...ownerIds]) }, + { ...where, albums: { id: In([...albumIds]) } }, + ], relations: { exifInfo: true, }, diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 5a61f70da8..2673e2436d 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException, UnauthorizedException } from '@nestjs/common'; import { mapAsset } from 'src/dtos/asset-response.dto'; import { AssetJobName, AssetStatsResponseDto, UploadFieldName } from 'src/dtos/asset.dto'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { AssetStats, IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; @@ -18,6 +19,7 @@ import { faceStub } from 'test/fixtures/face.stub'; import { partnerStub } from 'test/fixtures/partner.stub'; import { userStub } from 'test/fixtures/user.stub'; import { IAccessRepositoryMock, newAccessRepositoryMock } from 'test/repositories/access.repository.mock'; +import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; import { newAssetStackRepositoryMock } from 'test/repositories/asset-stack.repository.mock'; import { newAssetRepositoryMock } from 'test/repositories/asset.repository.mock'; import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; @@ -160,6 +162,7 @@ describe(AssetService.name, () => { let configMock: Mocked; let partnerMock: Mocked; let assetStackMock: Mocked; + let albumMock: Mocked; let loggerMock: Mocked; it('should work', () => { @@ -182,6 +185,7 @@ describe(AssetService.name, () => { configMock = newSystemConfigRepositoryMock(); partnerMock = newPartnerRepositoryMock(); assetStackMock = newAssetStackRepositoryMock(); + albumMock = newAlbumRepositoryMock(); loggerMock = newLoggerRepositoryMock(); sut = new AssetService( @@ -194,6 +198,7 @@ describe(AssetService.name, () => { eventMock, partnerMock, assetStackMock, + albumMock, loggerMock, ); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 5ffa940e7b..b1eff50a93 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -29,6 +29,7 @@ import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.entity'; import { LibraryType } from 'src/entities/library.entity'; import { IAccessRepository } from 'src/interfaces/access.interface'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; import { IAssetStackRepository } from 'src/interfaces/asset-stack.interface'; import { IAssetRepository } from 'src/interfaces/asset.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; @@ -78,6 +79,7 @@ export class AssetService { @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, @Inject(IAssetStackRepository) private assetStackRepository: IAssetStackRepository, + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(AssetService.name); @@ -167,6 +169,7 @@ export class AssetService { async getMapMarkers(auth: AuthDto, options: MapMarkerDto): Promise { const userIds: string[] = [auth.user.id]; + // TODO convert to SQL join if (options.withPartners) { const partners = await this.partnerRepository.getAll(auth.user.id); const partnersIds = partners @@ -174,7 +177,18 @@ export class AssetService { .map((partner) => partner.sharedById); userIds.push(...partnersIds); } - return this.assetRepository.getMapMarkers(userIds, options); + + // TODO convert to SQL join + const albumIds: string[] = []; + if (options.withSharedAlbums) { + const [ownedAlbums, sharedAlbums] = await Promise.all([ + this.albumRepository.getOwned(auth.user.id), + this.albumRepository.getShared(auth.user.id), + ]); + albumIds.push(...ownedAlbums.map((album) => album.id), ...sharedAlbums.map((album) => album.id)); + } + + return this.assetRepository.getMapMarkers(userIds, albumIds, options); } async getMemoryLane(auth: AuthDto, dto: MemoryLaneDto): Promise { diff --git a/web/src/lib/components/map-page/map-settings-modal.svelte b/web/src/lib/components/map-page/map-settings-modal.svelte index 460363722e..aded865a06 100644 --- a/web/src/lib/components/map-page/map-settings-modal.svelte +++ b/web/src/lib/components/map-page/map-settings-modal.svelte @@ -30,7 +30,12 @@ - + + {#if customDateRange}
diff --git a/web/src/lib/stores/preferences.store.ts b/web/src/lib/stores/preferences.store.ts index 591802f488..3e4bf09b28 100644 --- a/web/src/lib/stores/preferences.store.ts +++ b/web/src/lib/stores/preferences.store.ts @@ -47,6 +47,7 @@ export interface MapSettings { includeArchived: boolean; onlyFavorites: boolean; withPartners: boolean; + withSharedAlbums: boolean; relativeDate: string; dateAfter: string; dateBefore: string; @@ -57,6 +58,7 @@ export const mapSettings = persisted('map-settings', { includeArchived: false, onlyFavorites: false, withPartners: false, + withSharedAlbums: false, relativeDate: '', dateAfter: '', dateBefore: '', diff --git a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte index 593250a7c9..1b5923663b 100644 --- a/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte +++ b/web/src/routes/(user)/map/[[photos=photos]]/[[assetId=id]]/+page.svelte @@ -47,7 +47,7 @@ } abortController = new AbortController(); - const { includeArchived, onlyFavorites, withPartners } = $mapSettings; + const { includeArchived, onlyFavorites, withPartners, withSharedAlbums } = $mapSettings; const { fileCreatedAfter, fileCreatedBefore } = getFileCreatedDates(); return await getMapMarkers( @@ -57,6 +57,7 @@ fileCreatedAfter: fileCreatedAfter || undefined, fileCreatedBefore, withPartners: withPartners || undefined, + withSharedAlbums: withSharedAlbums || undefined, }, { signal: abortController.signal,