diff --git a/e2e/src/api/specs/asset.e2e-spec.ts b/e2e/src/api/specs/asset.e2e-spec.ts index caf032e130ccb..d5cece771dbd8 100644 --- a/e2e/src/api/specs/asset.e2e-spec.ts +++ b/e2e/src/api/specs/asset.e2e-spec.ts @@ -641,95 +641,6 @@ describe('/asset', () => { }); }); - describe('GET /asset/map-marker', () => { - beforeAll(async () => { - const files = [ - 'formats/avif/8bit-sRGB.avif', - 'formats/jpg/el_torcal_rocks.jpg', - 'formats/jxl/8bit-sRGB.jxl', - 'formats/heic/IMG_2682.heic', - 'formats/png/density_plot.png', - 'formats/raw/Nikon/D80/glarus.nef', - 'formats/raw/Nikon/D700/philadelphia.nef', - 'formats/raw/Panasonic/DMC-GH4/4_3.rw2', - 'formats/raw/Sony/ILCE-6300/12bit-compressed-(3_2).arw', - 'formats/raw/Sony/ILCE-7M2/14bit-uncompressed-(3_2).arw', - ]; - utils.resetEvents(); - const uploadFile = async (input: string) => { - const filepath = join(testAssetDir, input); - const { id } = await utils.createAsset(admin.accessToken, { - assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, - }); - await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); - }; - const uploads = files.map((f) => uploadFile(f)); - await Promise.all(uploads); - }, 30_000); - - it('should require authentication', async () => { - const { status, body } = await request(app).get('/asset/map-marker'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - // TODO archive one of these assets - it('should get map markers for all non-archived assets', async () => { - const { status, body } = await request(app) - .get('/asset/map-marker') - .query({ isArchived: false }) - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toHaveLength(2); - expect(body).toEqual([ - { - city: 'Palisade', - country: 'United States of America', - id: expect.any(String), - lat: expect.closeTo(39.115), - lon: expect.closeTo(-108.400_968), - state: 'Colorado', - }, - { - city: 'Ralston', - country: 'United States of America', - id: expect.any(String), - lat: expect.closeTo(41.2203), - lon: expect.closeTo(-96.071_625), - state: 'Nebraska', - }, - ]); - }); - - // TODO archive one of these assets - it('should get all map markers', async () => { - const { status, body } = await request(app) - .get('/asset/map-marker') - .set('Authorization', `Bearer ${admin.accessToken}`); - - expect(status).toBe(200); - expect(body).toEqual([ - { - city: 'Palisade', - country: 'United States of America', - id: expect.any(String), - lat: expect.closeTo(39.115), - lon: expect.closeTo(-108.400_968), - state: 'Colorado', - }, - { - city: 'Ralston', - country: 'United States of America', - id: expect.any(String), - lat: expect.closeTo(41.2203), - lon: expect.closeTo(-96.071_625), - state: 'Nebraska', - }, - ]); - }); - }); - describe('PUT /asset', () => { it('should require authentication', async () => { const { status, body } = await request(app).put('/asset'); diff --git a/e2e/src/api/specs/map.e2e-spec.ts b/e2e/src/api/specs/map.e2e-spec.ts new file mode 100644 index 0000000000000..2a0defc724e12 --- /dev/null +++ b/e2e/src/api/specs/map.e2e-spec.ts @@ -0,0 +1,162 @@ +import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType } from '@immich/sdk'; +import { readFile } from 'node:fs/promises'; +import { basename, join } from 'node:path'; +import { Socket } from 'socket.io-client'; +import { createUserDto } from 'src/fixtures'; +import { errorDto } from 'src/responses'; +import { app, testAssetDir, utils } from 'src/utils'; +import request from 'supertest'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +describe('/map', () => { + let websocket: Socket; + let admin: LoginResponseDto; + let nonAdmin: LoginResponseDto; + let asset: AssetFileUploadResponseDto; + + beforeAll(async () => { + await utils.resetDatabase(); + admin = await utils.adminSetup({ onboarding: false }); + nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); + + websocket = await utils.connectWebsocket(admin.accessToken); + + asset = await utils.createAsset(admin.accessToken); + + const files = ['formats/heic/IMG_2682.heic', 'metadata/gps-position/thompson-springs.jpg']; + utils.resetEvents(); + const uploadFile = async (input: string) => { + const filepath = join(testAssetDir, input); + const { id } = await utils.createAsset(admin.accessToken, { + assetData: { bytes: await readFile(filepath), filename: basename(filepath) }, + }); + await utils.waitForWebsocketEvent({ event: 'assetUpload', id }); + }; + await Promise.all(files.map((f) => uploadFile(f))); + }); + + afterAll(() => { + utils.disconnectWebsocket(websocket); + }); + + describe('GET /map/markers', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/map/markers'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + // TODO archive one of these assets + it('should get map markers for all non-archived assets', async () => { + const { status, body } = await request(app) + .get('/map/markers') + .query({ isArchived: false }) + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toHaveLength(2); + expect(body).toEqual([ + { + city: 'Palisade', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(39.115), + lon: expect.closeTo(-108.400_968), + state: 'Colorado', + }, + { + city: 'Ralston', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(41.2203), + lon: expect.closeTo(-96.071_625), + state: 'Nebraska', + }, + ]); + }); + + // TODO archive one of these assets + it('should get all map markers', async () => { + const { status, body } = await request(app) + .get('/map/markers') + .set('Authorization', `Bearer ${admin.accessToken}`); + + expect(status).toBe(200); + expect(body).toEqual([ + { + city: 'Palisade', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(39.115), + lon: expect.closeTo(-108.400_968), + state: 'Colorado', + }, + { + city: 'Ralston', + country: 'United States of America', + id: expect.any(String), + lat: expect.closeTo(41.2203), + lon: expect.closeTo(-96.071_625), + state: 'Nebraska', + }, + ]); + }); + }); + + describe('GET /map/style.json', () => { + it('should require authentication', async () => { + const { status, body } = await request(app).get('/map/style.json'); + expect(status).toBe(401); + expect(body).toEqual(errorDto.unauthorized); + }); + + it('should allow shared link access', async () => { + const sharedLink = await utils.createSharedLink(admin.accessToken, { + type: SharedLinkType.Individual, + assetIds: [asset.id], + }); + const { status, body } = await request(app).get(`/map/style.json?key=${sharedLink.key}`).query({ theme: 'dark' }); + + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); + }); + + it('should throw an error if a theme is not light or dark', async () => { + for (const theme of ['dark1', true, 123, '', null, undefined]) { + const { status, body } = await request(app) + .get('/map/style.json') + .query({ theme }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(400); + expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark'])); + } + }); + + it('should return the light style.json', async () => { + const { status, body } = await request(app) + .get('/map/style.json') + .query({ theme: 'light' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' })); + }); + + it('should return the dark style.json', async () => { + const { status, body } = await request(app) + .get('/map/style.json') + .query({ theme: 'dark' }) + .set('Authorization', `Bearer ${admin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); + }); + + it('should not require admin authentication', async () => { + const { status, body } = await request(app) + .get('/map/style.json') + .query({ theme: 'dark' }) + .set('Authorization', `Bearer ${nonAdmin.accessToken}`); + expect(status).toBe(200); + expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); + }); + }); +}); diff --git a/e2e/src/api/specs/system-config.e2e-spec.ts b/e2e/src/api/specs/system-config.e2e-spec.ts index 6be2683898e15..060163d7c9867 100644 --- a/e2e/src/api/specs/system-config.e2e-spec.ts +++ b/e2e/src/api/specs/system-config.e2e-spec.ts @@ -1,5 +1,4 @@ -import { AssetFileUploadResponseDto, LoginResponseDto, SharedLinkType, getConfig } from '@immich/sdk'; -import { createUserDto } from 'src/fixtures'; +import { LoginResponseDto, getConfig } from '@immich/sdk'; import { errorDto } from 'src/responses'; import { app, asBearerAuth, utils } from 'src/utils'; import request from 'supertest'; @@ -9,74 +8,10 @@ const getSystemConfig = (accessToken: string) => getConfig({ headers: asBearerAu describe('/system-config', () => { let admin: LoginResponseDto; - let nonAdmin: LoginResponseDto; - let asset: AssetFileUploadResponseDto; beforeAll(async () => { await utils.resetDatabase(); admin = await utils.adminSetup(); - nonAdmin = await utils.userSetup(admin.accessToken, createUserDto.user1); - - asset = await utils.createAsset(admin.accessToken); - }); - - describe('GET /system-config/map/style.json', () => { - it('should require authentication', async () => { - const { status, body } = await request(app).get('/system-config/map/style.json'); - expect(status).toBe(401); - expect(body).toEqual(errorDto.unauthorized); - }); - - it('should allow shared link access', async () => { - const sharedLink = await utils.createSharedLink(admin.accessToken, { - type: SharedLinkType.Individual, - assetIds: [asset.id], - }); - const { status, body } = await request(app) - .get(`/system-config/map/style.json?key=${sharedLink.key}`) - .query({ theme: 'dark' }); - - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should throw an error if a theme is not light or dark', async () => { - for (const theme of ['dark1', true, 123, '', null, undefined]) { - const { status, body } = await request(app) - .get('/system-config/map/style.json') - .query({ theme }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(400); - expect(body).toEqual(errorDto.badRequest(['theme must be one of the following values: light, dark'])); - } - }); - - it('should return the light style.json', async () => { - const { status, body } = await request(app) - .get('/system-config/map/style.json') - .query({ theme: 'light' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-light' })); - }); - - it('should return the dark style.json', async () => { - const { status, body } = await request(app) - .get('/system-config/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${admin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); - - it('should not require admin authentication', async () => { - const { status, body } = await request(app) - .get('/system-config/map/style.json') - .query({ theme: 'dark' }) - .set('Authorization', `Bearer ${nonAdmin.accessToken}`); - expect(status).toBe(200); - expect(body).toEqual(expect.objectContaining({ id: 'immich-map-dark' })); - }); }); describe('PUT /system-config', () => { diff --git a/mobile/lib/providers/map/map_state.provider.dart b/mobile/lib/providers/map/map_state.provider.dart index 2fb1c3e51d243..6d1630bba2e18 100644 --- a/mobile/lib/providers/map/map_state.provider.dart +++ b/mobile/lib/providers/map/map_state.provider.dart @@ -46,7 +46,7 @@ class MapStateNotifier extends _$MapStateNotifier { // Fetch and save light theme final lightResponse = await ref .read(apiServiceProvider) - .systemConfigApi + .mapApi .getMapStyleWithHttpInfo(MapTheme.light); if (lightResponse.statusCode >= HttpStatus.badRequest) { @@ -74,7 +74,7 @@ class MapStateNotifier extends _$MapStateNotifier { // Fetch and save dark theme final darkResponse = await ref .read(apiServiceProvider) - .systemConfigApi + .mapApi .getMapStyleWithHttpInfo(MapTheme.dark); if (darkResponse.statusCode >= HttpStatus.badRequest) { diff --git a/mobile/lib/services/api.service.dart b/mobile/lib/services/api.service.dart index 07b9a6e17727f..0421f515ec4a6 100644 --- a/mobile/lib/services/api.service.dart +++ b/mobile/lib/services/api.service.dart @@ -19,6 +19,7 @@ class ApiService { late AssetApi assetApi; late SearchApi searchApi; late ServerInfoApi serverInfoApi; + late MapApi mapApi; late PartnerApi partnerApi; late PersonApi personApi; late AuditApi auditApi; @@ -50,6 +51,7 @@ class ApiService { assetApi = AssetApi(_apiClient); serverInfoApi = ServerInfoApi(_apiClient); searchApi = SearchApi(_apiClient); + mapApi = MapApi(_apiClient); partnerApi = PartnerApi(_apiClient); personApi = PersonApi(_apiClient); auditApi = AuditApi(_apiClient); diff --git a/mobile/lib/services/map.service.dart b/mobile/lib/services/map.service.dart index 9ab461d63a60f..26a0746414db5 100644 --- a/mobile/lib/services/map.service.dart +++ b/mobile/lib/services/map.service.dart @@ -19,7 +19,7 @@ class MapSerivce with ErrorLoggerMixin { }) async { return logError( () async { - final markers = await _apiService.assetApi.getMapMarkers( + final markers = await _apiService.mapApi.getMapMarkers( isFavorite: isFavorite, isArchived: withArchived, withPartners: withPartners, diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index cdc75d4f28f2e..4a5fdfadd86f7 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -100,7 +100,6 @@ Class | Method | HTTP request | Description *AssetApi* | [**getAssetInfo**](doc//AssetApi.md#getassetinfo) | **GET** /asset/{id} | *AssetApi* | [**getAssetStatistics**](doc//AssetApi.md#getassetstatistics) | **GET** /asset/statistics | *AssetApi* | [**getAssetThumbnail**](doc//AssetApi.md#getassetthumbnail) | **GET** /asset/thumbnail/{id} | -*AssetApi* | [**getMapMarkers**](doc//AssetApi.md#getmapmarkers) | **GET** /asset/map-marker | *AssetApi* | [**getMemoryLane**](doc//AssetApi.md#getmemorylane) | **GET** /asset/memory-lane | *AssetApi* | [**getRandom**](doc//AssetApi.md#getrandom) | **GET** /asset/random | *AssetApi* | [**replaceAsset**](doc//AssetApi.md#replaceasset) | **PUT** /asset/{id}/file | @@ -136,6 +135,8 @@ Class | Method | HTTP request | Description *LibraryApi* | [**scanLibrary**](doc//LibraryApi.md#scanlibrary) | **POST** /libraries/{id}/scan | *LibraryApi* | [**updateLibrary**](doc//LibraryApi.md#updatelibrary) | **PUT** /libraries/{id} | *LibraryApi* | [**validate**](doc//LibraryApi.md#validate) | **POST** /libraries/{id}/validate | +*MapApi* | [**getMapMarkers**](doc//MapApi.md#getmapmarkers) | **GET** /map/markers | +*MapApi* | [**getMapStyle**](doc//MapApi.md#getmapstyle) | **GET** /map/style.json | *MemoryApi* | [**addMemoryAssets**](doc//MemoryApi.md#addmemoryassets) | **PUT** /memories/{id}/assets | *MemoryApi* | [**createMemory**](doc//MemoryApi.md#creatememory) | **POST** /memories | *MemoryApi* | [**deleteMemory**](doc//MemoryApi.md#deletememory) | **DELETE** /memories/{id} | @@ -192,7 +193,6 @@ Class | Method | HTTP request | Description *SyncApi* | [**getFullSyncForUser**](doc//SyncApi.md#getfullsyncforuser) | **POST** /sync/full-sync | *SystemConfigApi* | [**getConfig**](doc//SystemConfigApi.md#getconfig) | **GET** /system-config | *SystemConfigApi* | [**getConfigDefaults**](doc//SystemConfigApi.md#getconfigdefaults) | **GET** /system-config/defaults | -*SystemConfigApi* | [**getMapStyle**](doc//SystemConfigApi.md#getmapstyle) | **GET** /system-config/map/style.json | *SystemConfigApi* | [**getStorageTemplateOptions**](doc//SystemConfigApi.md#getstoragetemplateoptions) | **GET** /system-config/storage-template-options | *SystemConfigApi* | [**updateConfig**](doc//SystemConfigApi.md#updateconfig) | **PUT** /system-config | *SystemMetadataApi* | [**getAdminOnboarding**](doc//SystemMetadataApi.md#getadminonboarding) | **GET** /system-metadata/admin-onboarding | diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 94303a768fefc..9a11efd0cff2d 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -41,6 +41,7 @@ part 'api/face_api.dart'; part 'api/file_report_api.dart'; part 'api/job_api.dart'; part 'api/library_api.dart'; +part 'api/map_api.dart'; part 'api/memory_api.dart'; part 'api/o_auth_api.dart'; part 'api/partner_api.dart'; diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 58af13bd3d54a..7350ed25c6dd4 100644 --- a/mobile/openapi/lib/api/asset_api.dart +++ b/mobile/openapi/lib/api/asset_api.dart @@ -399,95 +399,6 @@ class AssetApi { return null; } - /// Performs an HTTP 'GET /asset/map-marker' operation and returns the [Response]. - /// Parameters: - /// - /// * [DateTime] fileCreatedAfter: - /// - /// * [DateTime] fileCreatedBefore: - /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// - /// * [bool] withPartners: - /// - /// * [bool] withSharedAlbums: - Future getMapMarkersWithHttpInfo({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { - // ignore: prefer_const_declarations - final path = r'/asset/map-marker'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (fileCreatedAfter != null) { - queryParams.addAll(_queryParams('', 'fileCreatedAfter', fileCreatedAfter)); - } - if (fileCreatedBefore != null) { - queryParams.addAll(_queryParams('', 'fileCreatedBefore', fileCreatedBefore)); - } - if (isArchived != null) { - queryParams.addAll(_queryParams('', 'isArchived', isArchived)); - } - if (isFavorite != null) { - queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); - } - if (withPartners != null) { - queryParams.addAll(_queryParams('', 'withPartners', withPartners)); - } - if (withSharedAlbums != null) { - queryParams.addAll(_queryParams('', 'withSharedAlbums', withSharedAlbums)); - } - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [DateTime] fileCreatedAfter: - /// - /// * [DateTime] fileCreatedBefore: - /// - /// * [bool] isArchived: - /// - /// * [bool] isFavorite: - /// - /// * [bool] withPartners: - /// - /// * [bool] withSharedAlbums: - Future?> getMapMarkers({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { - final response = await getMapMarkersWithHttpInfo( fileCreatedAfter: fileCreatedAfter, fileCreatedBefore: fileCreatedBefore, isArchived: isArchived, isFavorite: isFavorite, withPartners: withPartners, withSharedAlbums: withSharedAlbums, ); - 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; - } - /// Performs an HTTP 'GET /asset/memory-lane' operation and returns the [Response]. /// Parameters: /// diff --git a/mobile/openapi/lib/api/map_api.dart b/mobile/openapi/lib/api/map_api.dart new file mode 100644 index 0000000000000..7a33498c73053 --- /dev/null +++ b/mobile/openapi/lib/api/map_api.dart @@ -0,0 +1,163 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + + +class MapApi { + MapApi([ApiClient? apiClient]) : apiClient = apiClient ?? defaultApiClient; + + final ApiClient apiClient; + + /// Performs an HTTP 'GET /map/markers' operation and returns the [Response]. + /// Parameters: + /// + /// * [DateTime] fileCreatedAfter: + /// + /// * [DateTime] fileCreatedBefore: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// + /// * [bool] withPartners: + /// + /// * [bool] withSharedAlbums: + Future getMapMarkersWithHttpInfo({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { + // ignore: prefer_const_declarations + final path = r'/map/markers'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (fileCreatedAfter != null) { + queryParams.addAll(_queryParams('', 'fileCreatedAfter', fileCreatedAfter)); + } + if (fileCreatedBefore != null) { + queryParams.addAll(_queryParams('', 'fileCreatedBefore', fileCreatedBefore)); + } + if (isArchived != null) { + queryParams.addAll(_queryParams('', 'isArchived', isArchived)); + } + if (isFavorite != null) { + queryParams.addAll(_queryParams('', 'isFavorite', isFavorite)); + } + if (withPartners != null) { + queryParams.addAll(_queryParams('', 'withPartners', withPartners)); + } + if (withSharedAlbums != null) { + queryParams.addAll(_queryParams('', 'withSharedAlbums', withSharedAlbums)); + } + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [DateTime] fileCreatedAfter: + /// + /// * [DateTime] fileCreatedBefore: + /// + /// * [bool] isArchived: + /// + /// * [bool] isFavorite: + /// + /// * [bool] withPartners: + /// + /// * [bool] withSharedAlbums: + Future?> getMapMarkers({ DateTime? fileCreatedAfter, DateTime? fileCreatedBefore, bool? isArchived, bool? isFavorite, bool? withPartners, bool? withSharedAlbums, }) async { + final response = await getMapMarkersWithHttpInfo( fileCreatedAfter: fileCreatedAfter, fileCreatedBefore: fileCreatedBefore, isArchived: isArchived, isFavorite: isFavorite, withPartners: withPartners, withSharedAlbums: withSharedAlbums, ); + 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; + } + + /// Performs an HTTP 'GET /map/style.json' operation and returns the [Response]. + /// Parameters: + /// + /// * [MapTheme] theme (required): + /// + /// * [String] key: + Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { + // ignore: prefer_const_declarations + final path = r'/map/style.json'; + + // ignore: prefer_final_locals + Object? postBody; + + final queryParams = []; + final headerParams = {}; + final formParams = {}; + + if (key != null) { + queryParams.addAll(_queryParams('', 'key', key)); + } + queryParams.addAll(_queryParams('', 'theme', theme)); + + const contentTypes = []; + + + return apiClient.invokeAPI( + path, + 'GET', + queryParams, + postBody, + headerParams, + formParams, + contentTypes.isEmpty ? null : contentTypes.first, + ); + } + + /// Parameters: + /// + /// * [MapTheme] theme (required): + /// + /// * [String] key: + Future getMapStyle(MapTheme theme, { String? key, }) async { + final response = await getMapStyleWithHttpInfo(theme, key: key, ); + if (response.statusCode >= HttpStatus.badRequest) { + throw ApiException(response.statusCode, await _decodeBodyBytes(response)); + } + // When a remote server returns no body with a status of 204, we shall not decode it. + // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" + // FormatException when trying to decode an empty string. + if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { + return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; + + } + return null; + } +} diff --git a/mobile/openapi/lib/api/system_config_api.dart b/mobile/openapi/lib/api/system_config_api.dart index 1a5f381b43ce9..b63b2b70c431e 100644 --- a/mobile/openapi/lib/api/system_config_api.dart +++ b/mobile/openapi/lib/api/system_config_api.dart @@ -98,62 +98,6 @@ class SystemConfigApi { return null; } - /// Performs an HTTP 'GET /system-config/map/style.json' operation and returns the [Response]. - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyleWithHttpInfo(MapTheme theme, { String? key, }) async { - // ignore: prefer_const_declarations - final path = r'/system-config/map/style.json'; - - // ignore: prefer_final_locals - Object? postBody; - - final queryParams = []; - final headerParams = {}; - final formParams = {}; - - if (key != null) { - queryParams.addAll(_queryParams('', 'key', key)); - } - queryParams.addAll(_queryParams('', 'theme', theme)); - - const contentTypes = []; - - - return apiClient.invokeAPI( - path, - 'GET', - queryParams, - postBody, - headerParams, - formParams, - contentTypes.isEmpty ? null : contentTypes.first, - ); - } - - /// Parameters: - /// - /// * [MapTheme] theme (required): - /// - /// * [String] key: - Future getMapStyle(MapTheme theme, { String? key, }) async { - final response = await getMapStyleWithHttpInfo(theme, key: key, ); - if (response.statusCode >= HttpStatus.badRequest) { - throw ApiException(response.statusCode, await _decodeBodyBytes(response)); - } - // When a remote server returns no body with a status of 204, we shall not decode it. - // At the time of writing this, `dart:convert` will throw an "Unexpected end of input" - // FormatException when trying to decode an empty string. - if (response.body.isNotEmpty && response.statusCode != HttpStatus.noContent) { - return await apiClient.deserializeAsync(await _decodeBodyBytes(response), 'Object',) as Object; - - } - return null; - } - /// Performs an HTTP 'GET /system-config/storage-template-options' operation and returns the [Response]. Future getStorageTemplateOptionsWithHttpInfo() async { // ignore: prefer_const_declarations diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index d87599486550d..7c84e13ee5616 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -1598,92 +1598,6 @@ ] } }, - "/asset/map-marker": { - "get": { - "operationId": "getMapMarkers", - "parameters": [ - { - "name": "fileCreatedAfter", - "required": false, - "in": "query", - "schema": { - "format": "date-time", - "type": "string" - } - }, - { - "name": "fileCreatedBefore", - "required": false, - "in": "query", - "schema": { - "format": "date-time", - "type": "string" - } - }, - { - "name": "isArchived", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "isFavorite", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withPartners", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - }, - { - "name": "withSharedAlbums", - "required": false, - "in": "query", - "schema": { - "type": "boolean" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "items": { - "$ref": "#/components/schemas/MapMarkerResponseDto" - }, - "type": "array" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "Asset" - ] - } - }, "/asset/memory-lane": { "get": { "operationId": "getMemoryLane", @@ -3131,6 +3045,141 @@ ] } }, + "/map/markers": { + "get": { + "operationId": "getMapMarkers", + "parameters": [ + { + "name": "fileCreatedAfter", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "fileCreatedBefore", + "required": false, + "in": "query", + "schema": { + "format": "date-time", + "type": "string" + } + }, + { + "name": "isArchived", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "isFavorite", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withPartners", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + }, + { + "name": "withSharedAlbums", + "required": false, + "in": "query", + "schema": { + "type": "boolean" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/MapMarkerResponseDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Map" + ] + } + }, + "/map/style.json": { + "get": { + "operationId": "getMapStyle", + "parameters": [ + { + "name": "key", + "required": false, + "in": "query", + "schema": { + "type": "string" + } + }, + { + "name": "theme", + "required": true, + "in": "query", + "schema": { + "$ref": "#/components/schemas/MapTheme" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "tags": [ + "Map" + ] + } + }, "/memories": { "get": { "operationId": "searchMemories", @@ -5512,55 +5561,6 @@ ] } }, - "/system-config/map/style.json": { - "get": { - "operationId": "getMapStyle", - "parameters": [ - { - "name": "key", - "required": false, - "in": "query", - "schema": { - "type": "string" - } - }, - { - "name": "theme", - "required": true, - "in": "query", - "schema": { - "$ref": "#/components/schemas/MapTheme" - } - } - ], - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "type": "object" - } - } - }, - "description": "" - } - }, - "security": [ - { - "bearer": [] - }, - { - "cookie": [] - }, - { - "api_key": [] - } - ], - "tags": [ - "System Config" - ] - } - }, "/system-config/storage-template-options": { "get": { "operationId": "getStorageTemplateOptions", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8030c92d4442c..c6d8bd65dea27 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -303,14 +303,6 @@ export type AssetJobsDto = { assetIds: string[]; name: AssetJobName; }; -export type MapMarkerResponseDto = { - city: string | null; - country: string | null; - id: string; - lat: number; - lon: number; - state: string | null; -}; export type MemoryLaneResponseDto = { assets: AssetResponseDto[]; yearsAgo: number; @@ -516,6 +508,14 @@ export type ValidateLibraryImportPathResponseDto = { export type ValidateLibraryResponseDto = { importPaths?: ValidateLibraryImportPathResponseDto[]; }; +export type MapMarkerResponseDto = { + city: string | null; + country: string | null; + id: string; + lat: number; + lon: number; + state: string | null; +}; export type OnThisDayDto = { year: number; }; @@ -1518,28 +1518,6 @@ export function runAssetJobs({ assetJobsDto }: { body: assetJobsDto }))); } -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; - data: MapMarkerResponseDto[]; - }>(`/asset/map-marker${QS.query(QS.explode({ - fileCreatedAfter, - fileCreatedBefore, - isArchived, - isFavorite, - withPartners, - withSharedAlbums - }))}`, { - ...opts - })); -} export function getMemoryLane({ day, month }: { day: number; month: number; @@ -1930,6 +1908,42 @@ export function validate({ id, validateLibraryDto }: { body: validateLibraryDto }))); } +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; + data: MapMarkerResponseDto[]; + }>(`/map/markers${QS.query(QS.explode({ + fileCreatedAfter, + fileCreatedBefore, + isArchived, + isFavorite, + withPartners, + withSharedAlbums + }))}`, { + ...opts + })); +} +export function getMapStyle({ key, theme }: { + key?: string; + theme: MapTheme; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: object; + }>(`/map/style.json${QS.query(QS.explode({ + key, + theme + }))}`, { + ...opts + })); +} export function searchMemories(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2568,20 +2582,6 @@ export function getConfigDefaults(opts?: Oazapfts.RequestOpts) { ...opts })); } -export function getMapStyle({ key, theme }: { - key?: string; - theme: MapTheme; -}, opts?: Oazapfts.RequestOpts) { - return oazapfts.ok(oazapfts.fetchJson<{ - status: 200; - data: object; - }>(`/system-config/map/style.json${QS.query(QS.explode({ - key, - theme - }))}`, { - ...opts - })); -} export function getStorageTemplateOptions(opts?: Oazapfts.RequestOpts) { return oazapfts.ok(oazapfts.fetchJson<{ status: 200; @@ -2977,6 +2977,10 @@ export enum JobCommand { Empty = "empty", ClearFailed = "clear-failed" } +export enum MapTheme { + Light = "light", + Dark = "dark" +} export enum Type2 { OnThisDay = "on_this_day" } @@ -3073,10 +3077,6 @@ export enum ModelType { FacialRecognition = "facial-recognition", Clip = "clip" } -export enum MapTheme { - Light = "light", - Dark = "dark" -} export enum TimeBucketSize { Day = "DAY", Month = "MONTH" diff --git a/server/src/controllers/asset.controller.ts b/server/src/controllers/asset.controller.ts index f2d076e17b173..e7176a37c0181 100644 --- a/server/src/controllers/asset.controller.ts +++ b/server/src/controllers/asset.controller.ts @@ -12,7 +12,7 @@ import { UpdateAssetDto, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto'; +import { MemoryLaneDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { Auth, Authenticated } from 'src/middleware/auth.guard'; import { Route } from 'src/middleware/file-upload.interceptor'; @@ -24,12 +24,6 @@ import { UUIDParamDto } from 'src/validation'; export class AssetController { constructor(private service: AssetService) {} - @Get('map-marker') - @Authenticated() - getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise { - return this.service.getMapMarkers(auth, options); - } - @Get('memory-lane') @Authenticated() getMemoryLane(@Auth() auth: AuthDto, @Query() dto: MemoryLaneDto): Promise { diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index ca454b6a1d213..0f2112b0b4086 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -13,6 +13,7 @@ import { FaceController } from 'src/controllers/face.controller'; import { ReportController } from 'src/controllers/file-report.controller'; import { JobController } from 'src/controllers/job.controller'; import { LibraryController } from 'src/controllers/library.controller'; +import { MapController } from 'src/controllers/map.controller'; import { MemoryController } from 'src/controllers/memory.controller'; import { OAuthController } from 'src/controllers/oauth.controller'; import { PartnerController } from 'src/controllers/partner.controller'; @@ -45,6 +46,7 @@ export const controllers = [ FaceController, JobController, LibraryController, + MapController, MemoryController, OAuthController, PartnerController, diff --git a/server/src/controllers/map.controller.ts b/server/src/controllers/map.controller.ts new file mode 100644 index 0000000000000..223e6b8147619 --- /dev/null +++ b/server/src/controllers/map.controller.ts @@ -0,0 +1,25 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { MapMarkerDto, MapMarkerResponseDto } from 'src/dtos/search.dto'; +import { MapThemeDto } from 'src/dtos/system-config.dto'; +import { Auth, Authenticated } from 'src/middleware/auth.guard'; +import { MapService } from 'src/services/map.service'; + +@ApiTags('Map') +@Controller('map') +export class MapController { + constructor(private service: MapService) {} + + @Get('markers') + @Authenticated() + getMapMarkers(@Auth() auth: AuthDto, @Query() options: MapMarkerDto): Promise { + return this.service.getMapMarkers(auth, options); + } + + @Authenticated({ sharedLink: true }) + @Get('style.json') + getMapStyle(@Query() dto: MapThemeDto) { + return this.service.getMapStyle(dto.theme); + } +} diff --git a/server/src/controllers/system-config.controller.ts b/server/src/controllers/system-config.controller.ts index bf9e8495f7c5f..e88f3dcb3929e 100644 --- a/server/src/controllers/system-config.controller.ts +++ b/server/src/controllers/system-config.controller.ts @@ -1,6 +1,6 @@ -import { Body, Controller, Get, Put, Query } from '@nestjs/common'; +import { Body, Controller, Get, Put } from '@nestjs/common'; import { ApiTags } from '@nestjs/swagger'; -import { MapThemeDto, SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; +import { SystemConfigDto, SystemConfigTemplateStorageOptionDto } from 'src/dtos/system-config.dto'; import { Authenticated } from 'src/middleware/auth.guard'; import { SystemConfigService } from 'src/services/system-config.service'; @@ -32,10 +32,4 @@ export class SystemConfigController { getStorageTemplateOptions(): SystemConfigTemplateStorageOptionDto { return this.service.getStorageTemplateOptions(); } - - @Authenticated({ sharedLink: true }) - @Get('map/style.json') - getMapStyle(@Query() dto: MapThemeDto) { - return this.service.getMapStyle(dto.theme); - } } diff --git a/server/src/interfaces/asset.interface.ts b/server/src/interfaces/asset.interface.ts index 63a2c5a7703e4..bd617b894c6a0 100644 --- a/server/src/interfaces/asset.interface.ts +++ b/server/src/interfaces/asset.interface.ts @@ -2,7 +2,6 @@ import { AssetOrder } from 'src/entities/album.entity'; import { AssetJobStatusEntity } from 'src/entities/asset-job-status.entity'; import { AssetEntity, AssetType } from 'src/entities/asset.entity'; import { ExifEntity } from 'src/entities/exif.entity'; -import { ReverseGeocodeResult } from 'src/interfaces/metadata.interface'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsOrder, FindOptionsRelations, FindOptionsSelect } from 'typeorm'; @@ -22,19 +21,6 @@ export interface LivePhotoSearchOptions { type: AssetType; } -export interface MapMarkerSearchOptions { - isArchived?: boolean; - isFavorite?: boolean; - fileCreatedBefore?: Date; - fileCreatedAfter?: Date; -} - -export interface MapMarker extends ReverseGeocodeResult { - id: string; - lat: number; - lon: number; -} - export enum WithoutProperty { THUMBNAIL = 'thumbnail', ENCODED_VIDEO = 'encoded-video', @@ -195,7 +181,6 @@ export interface IAssetRepository { softDeleteAll(ids: string[]): Promise; restoreAll(ids: string[]): Promise; findLivePhotoMatch(options: LivePhotoSearchOptions): 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/interfaces/map.interface.ts b/server/src/interfaces/map.interface.ts new file mode 100644 index 0000000000000..dce75ffd25b03 --- /dev/null +++ b/server/src/interfaces/map.interface.ts @@ -0,0 +1,32 @@ +export const IMapRepository = 'IMapRepository'; + +export interface MapMarkerSearchOptions { + isArchived?: boolean; + isFavorite?: boolean; + fileCreatedBefore?: Date; + fileCreatedAfter?: Date; +} + +export interface GeoPoint { + latitude: number; + longitude: number; +} + +export interface ReverseGeocodeResult { + country: string | null; + state: string | null; + city: string | null; +} + +export interface MapMarker extends ReverseGeocodeResult { + id: string; + lat: number; + lon: number; +} + +export interface IMapRepository { + init(): Promise; + reverseGeocode(point: GeoPoint): Promise; + getMapMarkers(ownerIds: string[], albumIds: string[], options?: MapMarkerSearchOptions): Promise; + fetchStyle(url: string): Promise; +} diff --git a/server/src/interfaces/metadata.interface.ts b/server/src/interfaces/metadata.interface.ts index aff74ef3612a9..1ccd704b59e3d 100644 --- a/server/src/interfaces/metadata.interface.ts +++ b/server/src/interfaces/metadata.interface.ts @@ -2,17 +2,6 @@ import { BinaryField, Tags } from 'exiftool-vendored'; export const IMetadataRepository = 'IMetadataRepository'; -export interface GeoPoint { - latitude: number; - longitude: number; -} - -export interface ReverseGeocodeResult { - country: string | null; - state: string | null; - city: string | null; -} - export interface ExifDuration { Value: number; Scale?: number; @@ -33,9 +22,7 @@ export interface ImmichTags extends Omit { } export interface IMetadataRepository { - init(): Promise; teardown(): Promise; - reverseGeocode(point: GeoPoint): Promise; readTags(path: string): Promise; writeTags(path: string, tags: Partial): Promise; extractBinaryTag(tagName: string, path: string): Promise; diff --git a/server/src/interfaces/system-metadata.interface.ts b/server/src/interfaces/system-metadata.interface.ts index 9bb9fd5077ba6..677474460f311 100644 --- a/server/src/interfaces/system-metadata.interface.ts +++ b/server/src/interfaces/system-metadata.interface.ts @@ -5,6 +5,5 @@ export const ISystemMetadataRepository = 'ISystemMetadataRepository'; export interface ISystemMetadataRepository { get(key: T): Promise; set(key: T, value: SystemMetadata[T]): Promise; - fetchStyle(url: string): Promise; readFile(filename: string): Promise; } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 356f78fee8837..01f76c9075fb9 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -21,8 +21,6 @@ import { AssetUpdateOptions, IAssetRepository, LivePhotoSearchOptions, - MapMarker, - MapMarkerSearchOptions, MonthDay, TimeBucketItem, TimeBucketOptions, @@ -31,7 +29,7 @@ import { WithoutProperty, } from 'src/interfaces/asset.interface'; import { AssetSearchOptions, SearchExploreItem } from 'src/interfaces/search.interface'; -import { OptionalBetween, searchAssetBuilder } from 'src/utils/database'; +import { searchAssetBuilder } from 'src/utils/database'; import { Instrumentation } from 'src/utils/instrumentation'; import { Paginated, PaginationMode, PaginationOptions, paginate, paginatedBuilder } from 'src/utils/pagination'; import { @@ -547,57 +545,6 @@ export class AssetRepository implements IAssetRepository { }); } - 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, - exifInfo: { - city: true, - state: true, - country: true, - latitude: true, - longitude: true, - }, - }, - where: [ - { ...where, ownerId: In([...ownerIds]) }, - { ...where, albums: { id: In([...albumIds]) } }, - ], - relations: { - exifInfo: true, - }, - order: { - fileCreatedAt: 'DESC', - }, - }); - - return assets.map((asset) => ({ - id: asset.id, - lat: asset.exifInfo!.latitude!, - lon: asset.exifInfo!.longitude!, - city: asset.exifInfo!.city, - state: asset.exifInfo!.state, - country: asset.exifInfo!.country, - })); - } - async getStatistics(ownerId: string, options: AssetStatsOptions): Promise { const builder = this.repository .createQueryBuilder('asset') diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 9ac9081c91472..3298f984e7f33 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -14,6 +14,7 @@ import { IJobRepository } from 'src/interfaces/job.interface'; import { ILibraryRepository } from 'src/interfaces/library.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { IMachineLearningRepository } from 'src/interfaces/machine-learning.interface'; +import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMemoryRepository } from 'src/interfaces/memory.interface'; import { IMetadataRepository } from 'src/interfaces/metadata.interface'; @@ -46,6 +47,7 @@ import { JobRepository } from 'src/repositories/job.repository'; import { LibraryRepository } from 'src/repositories/library.repository'; import { LoggerRepository } from 'src/repositories/logger.repository'; import { MachineLearningRepository } from 'src/repositories/machine-learning.repository'; +import { MapRepository } from 'src/repositories/map.repository'; import { MediaRepository } from 'src/repositories/media.repository'; import { MemoryRepository } from 'src/repositories/memory.repository'; import { MetadataRepository } from 'src/repositories/metadata.repository'; @@ -64,8 +66,8 @@ import { TagRepository } from 'src/repositories/tag.repository'; import { UserRepository } from 'src/repositories/user.repository'; export const repositories = [ - { provide: IActivityRepository, useClass: ActivityRepository }, { provide: IAccessRepository, useClass: AccessRepository }, + { provide: IActivityRepository, useClass: ActivityRepository }, { provide: IAlbumRepository, useClass: AlbumRepository }, { provide: IAlbumUserRepository, useClass: AlbumUserRepository }, { provide: IAssetRepository, useClass: AssetRepository }, @@ -76,10 +78,12 @@ export const repositories = [ { provide: IDatabaseRepository, useClass: DatabaseRepository }, { provide: IEventRepository, useClass: EventRepository }, { provide: IJobRepository, useClass: JobRepository }, - { provide: ILoggerRepository, useClass: LoggerRepository }, - { provide: ILibraryRepository, useClass: LibraryRepository }, { provide: IKeyRepository, useClass: ApiKeyRepository }, + { provide: ILibraryRepository, useClass: LibraryRepository }, + { provide: ILoggerRepository, useClass: LoggerRepository }, { provide: IMachineLearningRepository, useClass: MachineLearningRepository }, + { provide: IMapRepository, useClass: MapRepository }, + { provide: IMediaRepository, useClass: MediaRepository }, { provide: IMemoryRepository, useClass: MemoryRepository }, { provide: IMetadataRepository, useClass: MetadataRepository }, { provide: IMetricRepository, useClass: MetricRepository }, @@ -87,13 +91,12 @@ export const repositories = [ { provide: INotificationRepository, useClass: NotificationRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, - { provide: IServerInfoRepository, useClass: ServerInfoRepository }, - { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: ISearchRepository, useClass: SearchRepository }, + { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISessionRepository, useClass: SessionRepository }, + { provide: ISharedLinkRepository, useClass: SharedLinkRepository }, { provide: IStorageRepository, useClass: StorageRepository }, { provide: ISystemMetadataRepository, useClass: SystemMetadataRepository }, { provide: ITagRepository, useClass: TagRepository }, - { provide: IMediaRepository, useClass: MediaRepository }, { provide: IUserRepository, useClass: UserRepository }, ]; diff --git a/server/src/repositories/map.repository.ts b/server/src/repositories/map.repository.ts new file mode 100644 index 0000000000000..75ea8121fa7f1 --- /dev/null +++ b/server/src/repositories/map.repository.ts @@ -0,0 +1,246 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; +import { getName } from 'i18n-iso-countries'; +import { createReadStream, existsSync } from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import readLine from 'node:readline'; +import { citiesFile, geodataAdmin1Path, geodataAdmin2Path, geodataCities500Path, geodataDatePath } from 'src/constants'; +import { AssetEntity } from 'src/entities/asset.entity'; +import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; +import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { + GeoPoint, + IMapRepository, + MapMarker, + MapMarkerSearchOptions, + ReverseGeocodeResult, +} from 'src/interfaces/map.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { OptionalBetween } from 'src/utils/database'; +import { Instrumentation } from 'src/utils/instrumentation'; +import { DataSource, In, IsNull, Not, QueryRunner, Repository } from 'typeorm'; +import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; + +@Instrumentation() +@Injectable() +export class MapRepository implements IMapRepository { + constructor( + @InjectRepository(AssetEntity) private assetRepository: Repository, + @InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository, + @InjectDataSource() private dataSource: DataSource, + @Inject(ISystemMetadataRepository) private metadataRepository: ISystemMetadataRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + ) { + this.logger.setContext(MapRepository.name); + } + + async init(): Promise { + this.logger.log('Initializing metadata repository'); + const geodataDate = await readFile(geodataDatePath, 'utf8'); + + // TODO move to service init + const geocodingMetadata = await this.metadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); + if (geocodingMetadata?.lastUpdate === geodataDate) { + return; + } + + await this.importGeodata(); + + await this.metadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, { + lastUpdate: geodataDate, + lastImportFileName: citiesFile, + }); + + this.logger.log('Geodata import completed'); + } + + 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.assetRepository.find({ + select: { + id: true, + exifInfo: { + city: true, + state: true, + country: true, + latitude: true, + longitude: true, + }, + }, + where: [ + { ...where, ownerId: In([...ownerIds]) }, + { ...where, albums: { id: In([...albumIds]) } }, + ], + relations: { + exifInfo: true, + }, + order: { + fileCreatedAt: 'DESC', + }, + }); + + return assets.map((asset) => ({ + id: asset.id, + lat: asset.exifInfo!.latitude!, + lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + state: asset.exifInfo!.state, + country: asset.exifInfo!.country, + })); + } + + async fetchStyle(url: string) { + try { + const response = await fetch(url); + + if (!response.ok) { + throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); + } + + return response.json(); + } catch (error) { + throw new Error(`Failed to fetch data from ${url}: ${error}`); + } + } + + async reverseGeocode(point: GeoPoint): Promise { + this.logger.debug(`Request: ${point.latitude},${point.longitude}`); + + const response = await this.geodataPlacesRepository + .createQueryBuilder('geoplaces') + .where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point) + .orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")') + .limit(1) + .getOne(); + + if (!response) { + this.logger.warn( + `Response from database for reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`, + ); + return null; + } + + this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); + + const { countryCode, name: city, admin1Name } = response; + const country = getName(countryCode, 'en') ?? null; + const state = admin1Name; + + return { country, state, city }; + } + + private async importGeodata() { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + + const admin1 = await this.loadAdmin(geodataAdmin1Path); + const admin2 = await this.loadAdmin(geodataAdmin2Path); + + try { + await queryRunner.startTransaction(); + + await queryRunner.manager.clear(GeodataPlacesEntity); + await this.loadCities500(queryRunner, admin1, admin2); + + await queryRunner.commitTransaction(); + } catch (error) { + this.logger.fatal('Error importing geodata', error); + await queryRunner.rollbackTransaction(); + throw error; + } finally { + await queryRunner.release(); + } + } + + private async loadGeodataToTableFromFile( + queryRunner: QueryRunner, + lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity, + filePath: string, + options?: { entityFilter?: (linesplit: string[]) => boolean }, + ) { + const _entityFilter = options?.entityFilter ?? (() => true); + if (!existsSync(filePath)) { + this.logger.error(`Geodata file ${filePath} not found`); + throw new Error(`Geodata file ${filePath} not found`); + } + + const input = createReadStream(filePath); + let bufferGeodata: QueryDeepPartialEntity[] = []; + const lineReader = readLine.createInterface({ input }); + + for await (const line of lineReader) { + const lineSplit = line.split('\t'); + if (!_entityFilter(lineSplit)) { + continue; + } + const geoData = lineToEntityMapper(lineSplit); + bufferGeodata.push(geoData); + if (bufferGeodata.length > 1000) { + await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']); + bufferGeodata = []; + } + } + await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']); + } + + private async loadCities500( + queryRunner: QueryRunner, + admin1Map: Map, + admin2Map: Map, + ) { + await this.loadGeodataToTableFromFile( + queryRunner, + (lineSplit: string[]) => + this.geodataPlacesRepository.create({ + id: Number.parseInt(lineSplit[0]), + name: lineSplit[1], + alternateNames: lineSplit[3], + latitude: Number.parseFloat(lineSplit[4]), + longitude: Number.parseFloat(lineSplit[5]), + countryCode: lineSplit[8], + admin1Code: lineSplit[10], + admin2Code: lineSplit[11], + modificationDate: lineSplit[18], + admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`), + admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`), + }), + geodataCities500Path, + { entityFilter: (lineSplit) => lineSplit[7] != 'PPLX' }, + ); + } + + private async loadAdmin(filePath: string) { + if (!existsSync(filePath)) { + this.logger.error(`Geodata file ${filePath} not found`); + throw new Error(`Geodata file ${filePath} not found`); + } + + const input = createReadStream(filePath); + const lineReader = readLine.createInterface({ input: input }); + + const adminMap = new Map(); + for await (const line of lineReader) { + const lineSplit = line.split('\t'); + adminMap.set(lineSplit[0], lineSplit[1]); + } + + return adminMap; + } +} diff --git a/server/src/repositories/metadata.repository.ts b/server/src/repositories/metadata.repository.ts index 982368c07a0e2..5baf078299924 100644 --- a/server/src/repositories/metadata.repository.ts +++ b/server/src/repositories/metadata.repository.ts @@ -2,21 +2,13 @@ import { Inject, Injectable } from '@nestjs/common'; import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; import { DefaultReadTaskOptions, Tags, exiftool } from 'exiftool-vendored'; import geotz from 'geo-tz'; -import { getName } from 'i18n-iso-countries'; -import { createReadStream, existsSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import readLine from 'node:readline'; -import { citiesFile, geodataAdmin1Path, geodataAdmin2Path, geodataCities500Path, geodataDatePath } from 'src/constants'; import { DummyValue, GenerateSql } from 'src/decorators'; import { ExifEntity } from 'src/entities/exif.entity'; import { GeodataPlacesEntity } from 'src/entities/geodata-places.entity'; -import { SystemMetadataKey } from 'src/entities/system-metadata.entity'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; -import { GeoPoint, IMetadataRepository, ImmichTags, ReverseGeocodeResult } from 'src/interfaces/metadata.interface'; -import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { Instrumentation } from 'src/utils/instrumentation'; -import { DataSource, QueryRunner, Repository } from 'typeorm'; -import { QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity.js'; +import { DataSource, Repository } from 'typeorm'; @Instrumentation() @Injectable() @@ -24,162 +16,16 @@ export class MetadataRepository implements IMetadataRepository { constructor( @InjectRepository(ExifEntity) private exifRepository: Repository, @InjectRepository(GeodataPlacesEntity) private geodataPlacesRepository: Repository, - @Inject(ISystemMetadataRepository) - private systemMetadataRepository: ISystemMetadataRepository, @InjectDataSource() private dataSource: DataSource, @Inject(ILoggerRepository) private logger: ILoggerRepository, ) { this.logger.setContext(MetadataRepository.name); } - async init(): Promise { - this.logger.log('Initializing metadata repository'); - const geodataDate = await readFile(geodataDatePath, 'utf8'); - - // TODO move to metadata service init - const geocodingMetadata = await this.systemMetadataRepository.get(SystemMetadataKey.REVERSE_GEOCODING_STATE); - if (geocodingMetadata?.lastUpdate === geodataDate) { - return; - } - - await this.importGeodata(); - - await this.systemMetadataRepository.set(SystemMetadataKey.REVERSE_GEOCODING_STATE, { - lastUpdate: geodataDate, - lastImportFileName: citiesFile, - }); - - this.logger.log('Geodata import completed'); - } - - private async importGeodata() { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - - const admin1 = await this.loadAdmin(geodataAdmin1Path); - const admin2 = await this.loadAdmin(geodataAdmin2Path); - - try { - await queryRunner.startTransaction(); - - await queryRunner.manager.clear(GeodataPlacesEntity); - await this.loadCities500(queryRunner, admin1, admin2); - - await queryRunner.commitTransaction(); - } catch (error) { - this.logger.fatal('Error importing geodata', error); - await queryRunner.rollbackTransaction(); - throw error; - } finally { - await queryRunner.release(); - } - } - - private async loadGeodataToTableFromFile( - queryRunner: QueryRunner, - lineToEntityMapper: (lineSplit: string[]) => GeodataPlacesEntity, - filePath: string, - options?: { entityFilter?: (linesplit: string[]) => boolean }, - ) { - const _entityFilter = options?.entityFilter ?? (() => true); - if (!existsSync(filePath)) { - this.logger.error(`Geodata file ${filePath} not found`); - throw new Error(`Geodata file ${filePath} not found`); - } - - const input = createReadStream(filePath); - let bufferGeodata: QueryDeepPartialEntity[] = []; - const lineReader = readLine.createInterface({ input }); - - for await (const line of lineReader) { - const lineSplit = line.split('\t'); - if (!_entityFilter(lineSplit)) { - continue; - } - const geoData = lineToEntityMapper(lineSplit); - bufferGeodata.push(geoData); - if (bufferGeodata.length > 1000) { - await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']); - bufferGeodata = []; - } - } - await queryRunner.manager.upsert(GeodataPlacesEntity, bufferGeodata, ['id']); - } - - private async loadCities500( - queryRunner: QueryRunner, - admin1Map: Map, - admin2Map: Map, - ) { - await this.loadGeodataToTableFromFile( - queryRunner, - (lineSplit: string[]) => - this.geodataPlacesRepository.create({ - id: Number.parseInt(lineSplit[0]), - name: lineSplit[1], - alternateNames: lineSplit[3], - latitude: Number.parseFloat(lineSplit[4]), - longitude: Number.parseFloat(lineSplit[5]), - countryCode: lineSplit[8], - admin1Code: lineSplit[10], - admin2Code: lineSplit[11], - modificationDate: lineSplit[18], - admin1Name: admin1Map.get(`${lineSplit[8]}.${lineSplit[10]}`), - admin2Name: admin2Map.get(`${lineSplit[8]}.${lineSplit[10]}.${lineSplit[11]}`), - }), - geodataCities500Path, - { entityFilter: (lineSplit) => lineSplit[7] != 'PPLX' }, - ); - } - - private async loadAdmin(filePath: string) { - if (!existsSync(filePath)) { - this.logger.error(`Geodata file ${filePath} not found`); - throw new Error(`Geodata file ${filePath} not found`); - } - - const input = createReadStream(filePath); - const lineReader = readLine.createInterface({ input: input }); - - const adminMap = new Map(); - for await (const line of lineReader) { - const lineSplit = line.split('\t'); - adminMap.set(lineSplit[0], lineSplit[1]); - } - - return adminMap; - } - async teardown() { await exiftool.end(); } - async reverseGeocode(point: GeoPoint): Promise { - this.logger.debug(`Request: ${point.latitude},${point.longitude}`); - - const response = await this.geodataPlacesRepository - .createQueryBuilder('geoplaces') - .where('earth_box(ll_to_earth(:latitude, :longitude), 25000) @> "earthCoord"', point) - .orderBy('earth_distance(ll_to_earth(:latitude, :longitude), "earthCoord")') - .limit(1) - .getOne(); - - if (!response) { - this.logger.warn( - `Response from database for reverse geocoding latitude: ${point.latitude}, longitude: ${point.longitude} was null`, - ); - return null; - } - - this.logger.verbose(`Raw: ${JSON.stringify(response, null, 2)}`); - - const { countryCode, name: city, admin1Name } = response; - const country = getName(countryCode, 'en') ?? null; - const state = admin1Name; - - return { country, state, city }; - } - readTags(path: string): Promise { return exiftool .read(path, undefined, { diff --git a/server/src/repositories/system-metadata.repository.ts b/server/src/repositories/system-metadata.repository.ts index c8bf9489cbec1..aa03102502e30 100644 --- a/server/src/repositories/system-metadata.repository.ts +++ b/server/src/repositories/system-metadata.repository.ts @@ -26,20 +26,6 @@ export class SystemMetadataRepository implements ISystemMetadataRepository { await this.repository.upsert({ key, value }, { conflictPaths: { key: true } }); } - async fetchStyle(url: string) { - try { - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`Failed to fetch data from ${url} with status ${response.status}: ${await response.text()}`); - } - - return response.json(); - } catch (error) { - throw new Error(`Failed to fetch data from ${url}: ${error}`); - } - } - readFile(filename: string): Promise { return readFile(filename, { encoding: 'utf8' }); } diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index ca13adf31cbe5..8f85e1e5ce0b2 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -2,7 +2,6 @@ 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'; @@ -19,7 +18,6 @@ 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'; @@ -162,7 +160,6 @@ describe(AssetService.name, () => { let systemMock: Mocked; let partnerMock: Mocked; let assetStackMock: Mocked; - let albumMock: Mocked; let loggerMock: Mocked; it('should work', () => { @@ -185,7 +182,6 @@ describe(AssetService.name, () => { systemMock = newSystemMetadataRepositoryMock(); partnerMock = newPartnerRepositoryMock(); assetStackMock = newAssetStackRepositoryMock(); - albumMock = newAlbumRepositoryMock(); loggerMock = newLoggerRepositoryMock(); sut = new AssetService( @@ -198,7 +194,6 @@ describe(AssetService.name, () => { eventMock, partnerMock, assetStackMock, - albumMock, loggerMock, ); @@ -314,27 +309,6 @@ describe(AssetService.name, () => { }); }); - describe('getMapMarkers', () => { - it('should get geo information of assets', async () => { - const asset = assetStub.withLocation; - const marker = { - id: asset.id, - lat: asset.exifInfo!.latitude!, - lon: asset.exifInfo!.longitude!, - city: asset.exifInfo!.city, - state: asset.exifInfo!.state, - country: asset.exifInfo!.country, - }; - partnerMock.getAll.mockResolvedValue([]); - assetMock.getMapMarkers.mockResolvedValue([marker]); - - const markers = await sut.getMapMarkers(authStub.user1, {}); - - expect(markers).toHaveLength(1); - expect(markers[0]).toEqual(marker); - }); - }); - describe('getMemoryLane', () => { beforeAll(() => { vitest.useFakeTimers(); diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index 053f4ba9871ea..5272ac4027aa4 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -24,11 +24,10 @@ import { mapStats, } from 'src/dtos/asset.dto'; import { AuthDto } from 'src/dtos/auth.dto'; -import { MapMarkerDto, MapMarkerResponseDto, MemoryLaneDto } from 'src/dtos/search.dto'; +import { MemoryLaneDto } from 'src/dtos/search.dto'; import { UpdateStackParentDto } from 'src/dtos/stack.dto'; import { AssetEntity } from 'src/entities/asset.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'; @@ -65,7 +64,6 @@ 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); @@ -153,30 +151,6 @@ export class AssetService { return folder; } - 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 - .filter((partner) => partner.sharedBy && partner.sharedWith && partner.sharedById != auth.user.id) - .map((partner) => partner.sharedById); - userIds.push(...partnersIds); - } - - // 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 { const currentYear = new Date().getFullYear(); diff --git a/server/src/services/index.ts b/server/src/services/index.ts index eee0fac126741..b55bb8fd25d5f 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -13,6 +13,7 @@ import { DownloadService } from 'src/services/download.service'; import { DuplicateService } from 'src/services/duplicate.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; +import { MapService } from 'src/services/map.service'; import { MediaService } from 'src/services/media.service'; import { MemoryService } from 'src/services/memory.service'; import { MetadataService } from 'src/services/metadata.service'; @@ -38,11 +39,10 @@ import { UserService } from 'src/services/user.service'; import { VersionService } from 'src/services/version.service'; export const services = [ - ApiService, - MicroservicesService, APIKeyService, ActivityService, AlbumService, + ApiService, AssetMediaService, AssetService, AssetServiceV1, @@ -54,9 +54,11 @@ export const services = [ DuplicateService, JobService, LibraryService, + MapService, MediaService, MemoryService, MetadataService, + MicroservicesService, NotificationService, PartnerService, PersonService, @@ -73,7 +75,7 @@ export const services = [ TagService, TimelineService, TrashService, - UserService, UserAdminService, + UserService, VersionService, ]; diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts new file mode 100644 index 0000000000000..f8b73260aff3d --- /dev/null +++ b/server/src/services/map.service.spec.ts @@ -0,0 +1,54 @@ +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMapRepository } from 'src/interfaces/map.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { MapService } from 'src/services/map.service'; +import { assetStub } from 'test/fixtures/asset.stub'; +import { authStub } from 'test/fixtures/auth.stub'; +import { newAlbumRepositoryMock } from 'test/repositories/album.repository.mock'; +import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; +import { newPartnerRepositoryMock } from 'test/repositories/partner.repository.mock'; +import { newSystemMetadataRepositoryMock } from 'test/repositories/system-metadata.repository.mock'; +import { Mocked } from 'vitest'; + +describe(MapService.name, () => { + let sut: MapService; + let albumMock: Mocked; + let loggerMock: Mocked; + let partnerMock: Mocked; + let mapMock: Mocked; + let systemMetadataMock: Mocked; + + beforeEach(() => { + albumMock = newAlbumRepositoryMock(); + loggerMock = newLoggerRepositoryMock(); + partnerMock = newPartnerRepositoryMock(); + mapMock = newMapRepositoryMock(); + systemMetadataMock = newSystemMetadataRepositoryMock(); + + sut = new MapService(albumMock, loggerMock, partnerMock, mapMock, systemMetadataMock); + }); + + describe('getMapMarkers', () => { + it('should get geo information of assets', async () => { + const asset = assetStub.withLocation; + const marker = { + id: asset.id, + lat: asset.exifInfo!.latitude!, + lon: asset.exifInfo!.longitude!, + city: asset.exifInfo!.city, + state: asset.exifInfo!.state, + country: asset.exifInfo!.country, + }; + partnerMock.getAll.mockResolvedValue([]); + mapMock.getMapMarkers.mockResolvedValue([marker]); + + const markers = await sut.getMapMarkers(authStub.user1, {}); + + expect(markers).toHaveLength(1); + expect(markers[0]).toEqual(marker); + }); + }); +}); diff --git a/server/src/services/map.service.ts b/server/src/services/map.service.ts new file mode 100644 index 0000000000000..a08ddf0c1ae1f --- /dev/null +++ b/server/src/services/map.service.ts @@ -0,0 +1,59 @@ +import { Inject } from '@nestjs/common'; +import { SystemConfigCore } from 'src/cores/system-config.core'; +import { AuthDto } from 'src/dtos/auth.dto'; +import { MapMarkerDto, MapMarkerResponseDto } from 'src/dtos/search.dto'; +import { IAlbumRepository } from 'src/interfaces/album.interface'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMapRepository } from 'src/interfaces/map.interface'; +import { IPartnerRepository } from 'src/interfaces/partner.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; + +export class MapService { + private configCore: SystemConfigCore; + + constructor( + @Inject(IAlbumRepository) private albumRepository: IAlbumRepository, + @Inject(ILoggerRepository) private logger: ILoggerRepository, + @Inject(IPartnerRepository) private partnerRepository: IPartnerRepository, + @Inject(IMapRepository) private mapRepository: IMapRepository, + @Inject(ISystemMetadataRepository) private systemMetadataRepository: ISystemMetadataRepository, + ) { + this.logger.setContext(MapService.name); + this.configCore = SystemConfigCore.create(systemMetadataRepository, logger); + } + + 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 + .filter((partner) => partner.sharedBy && partner.sharedWith && partner.sharedById != auth.user.id) + .map((partner) => partner.sharedById); + userIds.push(...partnersIds); + } + + // 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.mapRepository.getMapMarkers(userIds, albumIds, options); + } + + async getMapStyle(theme: 'light' | 'dark') { + const { map } = await this.configCore.getConfig(); + const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle; + + if (styleUrl) { + return this.mapRepository.fetchStyle(styleUrl); + } + + return JSON.parse(await this.systemMetadataRepository.readFile(`./resources/style-${theme}.json`)); + } +} diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 59294bdcfc666..d981436ac7812 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -11,6 +11,7 @@ import { IDatabaseRepository } from 'src/interfaces/database.interface'; import { ClientEvent, IEventRepository } from 'src/interfaces/event.interface'; import { IJobRepository, JobName, JobStatus } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -29,6 +30,7 @@ import { newDatabaseRepositoryMock } from 'test/repositories/database.repository import { newEventRepositoryMock } from 'test/repositories/event.repository.mock'; import { newJobRepositoryMock } from 'test/repositories/job.repository.mock'; import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock'; +import { newMapRepositoryMock } from 'test/repositories/map.repository.mock'; import { newMediaRepositoryMock } from 'test/repositories/media.repository.mock'; import { newMetadataRepositoryMock } from 'test/repositories/metadata.repository.mock'; import { newMoveRepositoryMock } from 'test/repositories/move.repository.mock'; @@ -44,6 +46,7 @@ describe(MetadataService.name, () => { let systemMock: Mocked; let cryptoRepository: Mocked; let jobMock: Mocked; + let mapMock: Mocked; let metadataMock: Mocked; let moveMock: Mocked; let mediaMock: Mocked; @@ -60,6 +63,7 @@ describe(MetadataService.name, () => { assetMock = newAssetRepositoryMock(); cryptoRepository = newCryptoRepositoryMock(); jobMock = newJobRepositoryMock(); + mapMock = newMapRepositoryMock(); metadataMock = newMetadataRepositoryMock(); moveMock = newMoveRepositoryMock(); personMock = newPersonRepositoryMock(); @@ -78,6 +82,7 @@ describe(MetadataService.name, () => { cryptoRepository, databaseMock, jobMock, + mapMock, mediaMock, metadataMock, moveMock, @@ -102,7 +107,7 @@ describe(MetadataService.name, () => { await sut.init(); expect(jobMock.pause).toHaveBeenCalledTimes(1); - expect(metadataMock.init).toHaveBeenCalledTimes(1); + expect(mapMock.init).toHaveBeenCalledTimes(1); expect(jobMock.resume).toHaveBeenCalledTimes(1); }); @@ -112,7 +117,7 @@ describe(MetadataService.name, () => { await sut.init(); expect(jobMock.pause).not.toHaveBeenCalled(); - expect(metadataMock.init).not.toHaveBeenCalled(); + expect(mapMock.init).not.toHaveBeenCalled(); expect(jobMock.resume).not.toHaveBeenCalled(); }); }); @@ -297,7 +302,7 @@ describe(MetadataService.name, () => { it('should apply reverse geocoding', async () => { assetMock.getByIds.mockResolvedValue([assetStub.withLocation]); systemMock.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); - metadataMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); + mapMock.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); metadataMock.readTags.mockResolvedValue({ GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index a0b46ccbaafc1..df870183a9ce1 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -26,6 +26,7 @@ import { QueueName, } from 'src/interfaces/job.interface'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IMapRepository } from 'src/interfaces/map.interface'; import { IMediaRepository } from 'src/interfaces/media.interface'; import { IMetadataRepository, ImmichTags } from 'src/interfaces/metadata.interface'; import { IMoveRepository } from 'src/interfaces/move.interface'; @@ -108,6 +109,7 @@ export class MetadataService { @Inject(ICryptoRepository) private cryptoRepository: ICryptoRepository, @Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository, @Inject(IJobRepository) private jobRepository: IJobRepository, + @Inject(IMapRepository) private mapRepository: IMapRepository, @Inject(IMediaRepository) private mediaRepository: IMediaRepository, @Inject(IMetadataRepository) private repository: IMetadataRepository, @Inject(IMoveRepository) moveRepository: IMoveRepository, @@ -144,7 +146,7 @@ export class MetadataService { try { await this.jobRepository.pause(QueueName.METADATA_EXTRACTION); - await this.databaseRepository.withLock(DatabaseLock.GeodataImport, () => this.repository.init()); + await this.databaseRepository.withLock(DatabaseLock.GeodataImport, () => this.mapRepository.init()); await this.jobRepository.resume(QueueName.METADATA_EXTRACTION); this.logger.log(`Initialized local reverse geocoder`); @@ -337,7 +339,7 @@ export class MetadataService { } try { - const reverseGeocode = await this.repository.reverseGeocode({ latitude, longitude }); + const reverseGeocode = await this.mapRepository.reverseGeocode({ latitude, longitude }); if (!reverseGeocode) { return; } diff --git a/server/src/services/system-config.service.ts b/server/src/services/system-config.service.ts index e19888802074a..028a1fd323adb 100644 --- a/server/src/services/system-config.service.ts +++ b/server/src/services/system-config.service.ts @@ -31,7 +31,7 @@ export class SystemConfigService { private core: SystemConfigCore; constructor( - @Inject(ISystemMetadataRepository) private repository: ISystemMetadataRepository, + @Inject(ISystemMetadataRepository) repository: ISystemMetadataRepository, @Inject(IEventRepository) private eventRepository: IEventRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ISearchRepository) private smartInfoRepository: ISearchRepository, @@ -109,17 +109,6 @@ export class SystemConfigService { return options; } - async getMapStyle(theme: 'light' | 'dark') { - const { map } = await this.getConfig(); - const styleUrl = theme === 'dark' ? map.darkStyle : map.lightStyle; - - if (styleUrl) { - return this.repository.fetchStyle(styleUrl); - } - - return JSON.parse(await this.repository.readFile(`./resources/style-${theme}.json`)); - } - async getCustomCss(): Promise { const { theme } = await this.core.getConfig(); return theme.customCss; diff --git a/server/test/repositories/asset.repository.mock.ts b/server/test/repositories/asset.repository.mock.ts index abe56495db3e2..58f0ed7264e3b 100644 --- a/server/test/repositories/asset.repository.mock.ts +++ b/server/test/repositories/asset.repository.mock.ts @@ -31,7 +31,6 @@ export const newAssetRepositoryMock = (): Mocked => { update: vitest.fn(), remove: vitest.fn(), findLivePhotoMatch: vitest.fn(), - getMapMarkers: vitest.fn(), getStatistics: vitest.fn(), getTimeBucket: vitest.fn(), getTimeBuckets: vitest.fn(), diff --git a/server/test/repositories/map.repository.mock.ts b/server/test/repositories/map.repository.mock.ts new file mode 100644 index 0000000000000..95965522e34d8 --- /dev/null +++ b/server/test/repositories/map.repository.mock.ts @@ -0,0 +1,11 @@ +import { IMapRepository } from 'src/interfaces/map.interface'; +import { Mocked } from 'vitest'; + +export const newMapRepositoryMock = (): Mocked => { + return { + init: vitest.fn(), + reverseGeocode: vitest.fn(), + getMapMarkers: vitest.fn(), + fetchStyle: vitest.fn(), + }; +}; diff --git a/server/test/repositories/metadata.repository.mock.ts b/server/test/repositories/metadata.repository.mock.ts index 80d6bf121cfc3..5dbfb3d453c32 100644 --- a/server/test/repositories/metadata.repository.mock.ts +++ b/server/test/repositories/metadata.repository.mock.ts @@ -3,9 +3,7 @@ import { Mocked, vitest } from 'vitest'; export const newMetadataRepositoryMock = (): Mocked => { return { - init: vitest.fn(), teardown: vitest.fn(), - reverseGeocode: vitest.fn(), readTags: vitest.fn(), writeTags: vitest.fn(), extractBinaryTag: vitest.fn(), diff --git a/server/test/repositories/system-metadata.repository.mock.ts b/server/test/repositories/system-metadata.repository.mock.ts index d0cf4fe2e5d55..25efdbb01160c 100644 --- a/server/test/repositories/system-metadata.repository.mock.ts +++ b/server/test/repositories/system-metadata.repository.mock.ts @@ -11,6 +11,5 @@ export const newSystemMetadataRepositoryMock = (reset = true): Mocked