diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 0d41dbdcf4..df469cee3e 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -89,6 +89,7 @@ Class | Method | HTTP request | Description *AlbumsApi* | [**createAlbum**](doc//AlbumsApi.md#createalbum) | **POST** /albums | Create an album *AlbumsApi* | [**deleteAlbum**](doc//AlbumsApi.md#deletealbum) | **DELETE** /albums/{id} | Delete an album *AlbumsApi* | [**getAlbumInfo**](doc//AlbumsApi.md#getalbuminfo) | **GET** /albums/{id} | Retrieve an album +*AlbumsApi* | [**getAlbumMapMarkers**](doc//AlbumsApi.md#getalbummapmarkers) | **GET** /albums/{id}/map-markers | Retrieve album map markers *AlbumsApi* | [**getAlbumStatistics**](doc//AlbumsApi.md#getalbumstatistics) | **GET** /albums/statistics | Retrieve album statistics *AlbumsApi* | [**getAllAlbums**](doc//AlbumsApi.md#getallalbums) | **GET** /albums | List all albums *AlbumsApi* | [**removeAssetFromAlbum**](doc//AlbumsApi.md#removeassetfromalbum) | **DELETE** /albums/{id}/assets | Remove assets from an album diff --git a/mobile/openapi/lib/api/albums_api.dart b/mobile/openapi/lib/api/albums_api.dart index 000125ac7b..fa52ef2eb8 100644 --- a/mobile/openapi/lib/api/albums_api.dart +++ b/mobile/openapi/lib/api/albums_api.dart @@ -383,6 +383,81 @@ class AlbumsApi { return null; } + /// Retrieve album map markers + /// + /// Retrieve map marker information for a specific album by its ID. + /// + /// Note: This method returns the HTTP [Response]. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future getAlbumMapMarkersWithHttpInfo(String id, { String? key, String? slug, }) async { + // ignore: prefer_const_declarations + final apiPath = r'/albums/{id}/map-markers' + .replaceAll('{id}', id); + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + if (slug != null) { + queryParams.addAll(_queryParams('', 'slug', slug)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + apiPath, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Retrieve album map markers + /// + /// Retrieve map marker information for a specific album by its ID. + /// + /// Parameters: + /// + /// * [String] id (required): + /// + /// * [String] key: + /// + /// * [String] slug: + Future?> getAlbumMapMarkers(String id, { String? key, String? slug, }) async { + final response = await getAlbumMapMarkersWithHttpInfo(id, key: key, slug: slug, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + final responseBody = await _decodeBodyBytes(response); + return (await apiClient.deserializeAsync(responseBody, 'List') as List) + .cast() + .toList(growable: false); + + } + return null; + } + /// Retrieve album statistics /// /// Returns statistics about the albums available to the authenticated user. diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 2d83a302fe..99d77f9b24 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -2227,6 +2227,77 @@ "x-immich-state": "Stable" } }, + "/albums/{id}/map-markers": { + "get": { + "description": "Retrieve map marker information for a specific album by its ID.", + "operationId": "getAlbumMapMarkers", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + }, + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "slug", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MapMarkerResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Retrieve album map markers", + "tags": [ + "Albums" + ], + "x-immich-history": [ + { + "version": "v3", + "state": "Added" + } + ], + "x-immich-permission": "album.read" + } + }, "/albums/{id}/user/{userId}": { "delete": { "description": "Remove a user from an album. Use an ID of \"me\" to leave a shared album.", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index fd7b800f93..da38eaeace 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -710,6 +710,20 @@ export type BulkIdResponseDto = { /** Whether operation succeeded */ success: boolean; }; +export type MapMarkerResponseDto = { + /** City name */ + city: string | null; + /** Country name */ + country: string | null; + /** Asset ID */ + id: string; + /** Latitude */ + lat: number; + /** Longitude */ + lon: number; + /** State/Province name */ + state: string | null; +}; export type UpdateAlbumUserDto = { role: AlbumUserRole; }; @@ -1305,20 +1319,6 @@ export type ValidateLibraryResponseDto = { /** Validation results for import paths */ importPaths?: ValidateLibraryImportPathResponseDto[]; }; -export type MapMarkerResponseDto = { - /** City name */ - city: string | null; - /** Country name */ - country: string | null; - /** Asset ID */ - id: string; - /** Latitude */ - lat: number; - /** Longitude */ - lon: number; - /** State/Province name */ - state: string | null; -}; export type MapReverseGeocodeResponseDto = { /** City name */ city: string | null; @@ -3733,6 +3733,24 @@ export function addAssetsToAlbum({ id, bulkIdsDto }: { body: bulkIdsDto }))); } +/** + * Retrieve album map markers + */ +export function getAlbumMapMarkers({ id, key, slug }: { + id: string; + key?: string; + slug?: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: MapMarkerResponseDto[]; + }>(`/albums/${encodeURIComponent(id)}/map-markers${QS.query(QS.explode({ + key, + slug + }))}`, { + ...opts + })); +} /** * Remove user from album */ diff --git a/server/src/controllers/album.controller.ts b/server/src/controllers/album.controller.ts index df326a395f..62e3f53ad2 100644 --- a/server/src/controllers/album.controller.ts +++ b/server/src/controllers/album.controller.ts @@ -15,6 +15,7 @@ import { } from 'src/dtos/album.dto'; import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { MapMarkerResponseDto } from 'src/dtos/map.dto'; import { ApiTag, Permission } from 'src/enum'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { AlbumService } from 'src/services/album.service'; @@ -102,6 +103,17 @@ export class AlbumController { return this.service.delete(auth, id); } + @Authenticated({ permission: Permission.AlbumRead, sharedLink: true }) + @Get(':id/map-markers') + @Endpoint({ + summary: 'Retrieve album map markers', + description: 'Retrieve map marker information for a specific album by its ID.', + history: new HistoryBuilder().added('v3'), + }) + getAlbumMapMarkers(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto): Promise { + return this.service.getMapMarkers(auth, id); + } + @Put(':id/assets') @Authenticated({ permission: Permission.AlbumAssetCreate }) @Endpoint({ diff --git a/server/src/queries/map.repository.sql b/server/src/queries/map.repository.sql index d7e98b1cd2..8ae844b36c 100644 --- a/server/src/queries/map.repository.sql +++ b/server/src/queries/map.repository.sql @@ -1,5 +1,25 @@ -- NOTE: This file is auto generated by ./sql-generator +-- MapRepository.getAlbumMapMarkers +select + "id", + "asset_exif"."latitude" as "lat", + "asset_exif"."longitude" as "lon", + "asset_exif"."city", + "asset_exif"."state", + "asset_exif"."country" +from + "asset" + inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId" + and "asset_exif"."latitude" is not null + and "asset_exif"."longitude" is not null + inner join "album_asset" on "asset"."id" = "album_asset"."assetId" +where + "asset"."deletedAt" is null + and "album_asset"."albumId" = $1 +order by + "fileCreatedAt" desc + -- MapRepository.getMapMarkers select "id", @@ -14,8 +34,8 @@ from and "asset_exif"."latitude" is not null and "asset_exif"."longitude" is not null where - "asset"."visibility" = $1 - and "deletedAt" is null + "asset"."deletedAt" is null + and "asset"."visibility" = $1 and ( "ownerId" in ($2) or exists ( diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts index 304cf89c32..7c384b44b2 100644 --- a/server/src/repositories/map.repository.ts +++ b/server/src/repositories/map.repository.ts @@ -76,29 +76,21 @@ export class MapRepository { this.logger.log('Geodata import completed'); } + @GenerateSql({ params: [DummyValue.UUID] }) + getAlbumMapMarkers(albumId: string) { + return this.mapMarkersQuery() + .innerJoin('album_asset', 'asset.id', 'album_asset.assetId') + .where('album_asset.albumId', '=', albumId) + .execute(); + } + @GenerateSql({ params: [[DummyValue.UUID], [DummyValue.UUID]] }) getMapMarkers( ownerIds: string[], albumIds: string[], { isArchived, isFavorite, fileCreatedAfter, fileCreatedBefore }: MapMarkerSearchOptions = {}, ) { - return this.db - .selectFrom('asset') - .innerJoin('asset_exif', (builder) => - builder - .onRef('asset.id', '=', 'asset_exif.assetId') - .on('asset_exif.latitude', 'is not', null) - .on('asset_exif.longitude', 'is not', null), - ) - .select([ - 'id', - 'asset_exif.latitude as lat', - 'asset_exif.longitude as lon', - 'asset_exif.city', - 'asset_exif.state', - 'asset_exif.country', - ]) - .$narrowType<{ lat: NotNull; lon: NotNull }>() + return this.mapMarkersQuery() .$if(isArchived === true, (qb) => qb.where((eb) => eb.or([ @@ -113,7 +105,6 @@ export class MapRepository { .$if(isFavorite !== undefined, (q) => q.where('isFavorite', '=', isFavorite!)) .$if(fileCreatedAfter !== undefined, (q) => q.where('fileCreatedAt', '>=', fileCreatedAfter!)) .$if(fileCreatedBefore !== undefined, (q) => q.where('fileCreatedAt', '<=', fileCreatedBefore!)) - .where('deletedAt', 'is', null) .where((eb) => { const expression: Expression[] = []; @@ -134,10 +125,31 @@ export class MapRepository { return eb.or(expression); }) - .orderBy('fileCreatedAt', 'desc') .execute(); } + private mapMarkersQuery() { + return this.db + .selectFrom('asset') + .innerJoin('asset_exif', (builder) => + builder + .onRef('asset.id', '=', 'asset_exif.assetId') + .on('asset_exif.latitude', 'is not', null) + .on('asset_exif.longitude', 'is not', null), + ) + .where('asset.deletedAt', 'is', null) + .orderBy('fileCreatedAt', 'desc') + .select([ + 'id', + 'asset_exif.latitude as lat', + 'asset_exif.longitude as lon', + 'asset_exif.city', + 'asset_exif.state', + 'asset_exif.country', + ]) + .$narrowType<{ lat: NotNull; lon: NotNull }>(); + } + async reverseGeocode(point: GeoPoint): Promise { this.logger.debug(`Request: ${point.latitude},${point.longitude}`); diff --git a/server/src/services/album.service.ts b/server/src/services/album.service.ts index 24b9b165c9..1e93b8eace 100644 --- a/server/src/services/album.service.ts +++ b/server/src/services/album.service.ts @@ -17,6 +17,7 @@ import { } from 'src/dtos/album.dto'; import { BulkIdErrorReason, BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { MapMarkerResponseDto } from 'src/dtos/map.dto'; import { Permission } from 'src/enum'; import { AlbumAssetCount, AlbumInfoOptions } from 'src/repositories/album.repository'; import { BaseService } from 'src/services/base.service'; @@ -94,6 +95,16 @@ export class AlbumService extends BaseService { }; } + async getMapMarkers(auth: AuthDto, id: string): Promise { + await this.requireAccess({ auth, permission: Permission.AlbumRead, ids: [id] }); + + if (auth.sharedLink && !auth.sharedLink.showExif) { + return []; + } + + return this.mapRepository.getAlbumMapMarkers(id); + } + async create(auth: AuthDto, dto: CreateAlbumDto): Promise { const albumUsers = dto.albumUsers || []; diff --git a/web/src/lib/components/album-page/album-map.svelte b/web/src/lib/components/album-page/album-map.svelte index 623ac48ded..eb9d72b89c 100644 --- a/web/src/lib/components/album-page/album-map.svelte +++ b/web/src/lib/components/album-page/album-map.svelte @@ -2,8 +2,9 @@ import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte'; import MapModal from '$lib/modals/MapModal.svelte'; + import { handleError } from '$lib/utils/handle-error'; import { navigate } from '$lib/utils/navigation'; - import { getAlbumInfo, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk'; + import { getAlbumMapMarkers, type AlbumResponseDto, type MapMarkerResponseDto } from '@immich/sdk'; import { IconButton, modalManager } from '@immich/ui'; import { mdiMapOutline } from '@mdi/js'; import { onDestroy, onMount } from 'svelte'; @@ -14,7 +15,7 @@ } let { album }: Props = $props(); - let abortController: AbortController; + let cancelable: AbortController; let returnToMap = $state(false); let mapMarkers: MapMarkerResponseDto[] = $state([]); @@ -24,7 +25,7 @@ }); onDestroy(() => { - abortController?.abort(); + cancelable?.abort(); assetViewerManager.showAssetViewer(false); }); @@ -35,30 +36,17 @@ } }); - async function loadMapMarkers() { - if (abortController) { - abortController.abort(); + const loadMapMarkers = async () => { + cancelable?.abort(); + cancelable = new AbortController(); + + try { + return await getAlbumMapMarkers({ ...authManager.params, id: album.id }, { signal: cancelable.signal }); + } catch (error) { + handleError(error, $t('errors.something_went_wrong')); + return []; } - abortController = new AbortController(); - - let albumInfo: AlbumResponseDto = await getAlbumInfo({ id: album.id, withoutAssets: false, ...authManager.params }); - - let markers: MapMarkerResponseDto[] = []; - for (const asset of albumInfo.assets) { - if (asset.exifInfo?.latitude && asset.exifInfo?.longitude) { - markers.push({ - id: asset.id, - lat: asset.exifInfo.latitude, - lon: asset.exifInfo.longitude, - city: asset.exifInfo?.city ?? null, - country: asset.exifInfo?.country ?? null, - state: asset.exifInfo?.state ?? null, - }); - } - } - - return markers; - } + }; const onClick = async () => { const assetIds = await modalManager.show(MapModal, { mapMarkers });