mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-12-08 20:29:05 +00:00
feat(web+server): map improvements (#2498)
* feat(web+server): map improvements * add number format double to fix mobile
This commit is contained in:
@@ -31,7 +31,7 @@ import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
|
||||
import { ApiBody, ApiConsumes, ApiHeader, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
import { CuratedObjectsResponseDto } from './response-dto/curated-objects-response.dto';
|
||||
import { CuratedLocationsResponseDto } from './response-dto/curated-locations-response.dto';
|
||||
import { AssetResponseDto, ImmichReadStream, MapMarkerResponseDto } from '@app/domain';
|
||||
import { AssetResponseDto, ImmichReadStream } from '@app/domain';
|
||||
import { CheckDuplicateAssetResponseDto } from './response-dto/check-duplicate-asset-response.dto';
|
||||
import { CreateAssetDto, mapToUploadFile } from './dto/create-asset.dto';
|
||||
import { AssetFileUploadResponseDto } from './response-dto/asset-file-upload-response.dto';
|
||||
@@ -260,18 +260,6 @@ export class AssetController {
|
||||
return await this.assetService.getAssetByTimeBucket(authUser, getAssetByTimeBucketDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all assets that have GPS information embedded
|
||||
*/
|
||||
@Authenticated()
|
||||
@Get('/map-marker')
|
||||
getMapMarkers(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Query(new ValidationPipe({ transform: true })) dto: AssetSearchDto,
|
||||
): Promise<MapMarkerResponseDto[]> {
|
||||
return this.assetService.getMapMarkers(authUser, dto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all asset of a device that are in the database, ID only.
|
||||
*/
|
||||
|
||||
@@ -504,17 +504,4 @@ describe('AssetService', () => {
|
||||
expect(storageMock.createReadStream).toHaveBeenCalledWith('fake_path/asset_1.jpeg', 'image/jpeg');
|
||||
});
|
||||
});
|
||||
|
||||
describe('get map markers', () => {
|
||||
it('should get geo information of assets', async () => {
|
||||
assetRepositoryMock.getAllByUserId.mockResolvedValue(_getAssets());
|
||||
|
||||
const markers = await sut.getMapMarkers(authStub.admin, {});
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0].lat).toBe(_getAsset_1().exifInfo?.latitude);
|
||||
expect(markers[0].lon).toBe(_getAsset_1().exifInfo?.longitude);
|
||||
expect(markers[0].id).toBe(_getAsset_1().id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,8 +30,6 @@ import {
|
||||
JobName,
|
||||
mapAsset,
|
||||
mapAssetWithoutExif,
|
||||
MapMarkerResponseDto,
|
||||
mapAssetMapMarker,
|
||||
PartnerCore,
|
||||
} from '@app/domain';
|
||||
import { CreateAssetDto, UploadFile } from './dto/create-asset.dto';
|
||||
@@ -149,12 +147,6 @@ export class AssetService {
|
||||
return assets.map((asset) => mapAsset(asset));
|
||||
}
|
||||
|
||||
public async getMapMarkers(authUser: AuthUserDto, dto: AssetSearchDto): Promise<MapMarkerResponseDto[]> {
|
||||
const assets = await this._assetRepository.getAllByUserId(authUser.id, dto);
|
||||
|
||||
return assets.map((asset) => mapAssetMapMarker(asset)).filter((marker) => marker != null) as MapMarkerResponseDto[];
|
||||
}
|
||||
|
||||
public async getAssetByTimeBucket(
|
||||
authUser: AuthUserDto,
|
||||
getAssetByTimeBucketDto: GetAssetByTimeBucketDto,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { InfraModule } from '@app/infra';
|
||||
import {
|
||||
AlbumController,
|
||||
APIKeyController,
|
||||
AssetController,
|
||||
AuthController,
|
||||
PersonController,
|
||||
JobController,
|
||||
@@ -36,6 +37,7 @@ import { AppCronJobs } from './app.cron-jobs';
|
||||
AppController,
|
||||
AlbumController,
|
||||
APIKeyController,
|
||||
AssetController,
|
||||
AuthController,
|
||||
JobController,
|
||||
OAuthController,
|
||||
|
||||
20
server/apps/immich/src/controllers/asset.controller.ts
Normal file
20
server/apps/immich/src/controllers/asset.controller.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { AssetService, AuthUserDto, MapMarkerResponseDto } from '@app/domain';
|
||||
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { GetAuthUser } from '../decorators/auth-user.decorator';
|
||||
import { Authenticated } from '../decorators/authenticated.decorator';
|
||||
import { UseValidation } from '../decorators/use-validation.decorator';
|
||||
|
||||
@ApiTags('Asset')
|
||||
@Controller('asset')
|
||||
@Authenticated()
|
||||
@UseValidation()
|
||||
export class AssetController {
|
||||
constructor(private service: AssetService) {}
|
||||
|
||||
@Get('/map-marker')
|
||||
getMapMarkers(@GetAuthUser() authUser: AuthUserDto, @Query() options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
|
||||
return this.service.getMapMarkers(authUser, options);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './album.controller';
|
||||
export * from './api-key.controller';
|
||||
export * from './asset.controller';
|
||||
export * from './auth.controller';
|
||||
export * from './job.controller';
|
||||
export * from './oauth.controller';
|
||||
|
||||
@@ -295,6 +295,50 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/map-marker": {
|
||||
"get": {
|
||||
"operationId": "getMapMarkers",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "isFavorite",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MapMarkerResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Asset"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/auth/login": {
|
||||
"post": {
|
||||
"operationId": "login",
|
||||
@@ -2962,67 +3006,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/map-marker": {
|
||||
"get": {
|
||||
"operationId": "getMapMarkers",
|
||||
"description": "Get all assets that have GPS information embedded",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "isFavorite",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "isArchived",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "skip",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/MapMarkerResponseDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"Asset"
|
||||
],
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/asset/{deviceId}": {
|
||||
"get": {
|
||||
"operationId": "getUserAssetsByDeviceId",
|
||||
@@ -4579,6 +4562,27 @@
|
||||
"name"
|
||||
]
|
||||
},
|
||||
"MapMarkerResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"lat": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"lon": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"lat",
|
||||
"lon"
|
||||
]
|
||||
},
|
||||
"LoginCredentialDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5897,31 +5901,6 @@
|
||||
"timeBucket"
|
||||
]
|
||||
},
|
||||
"MapMarkerResponseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"$ref": "#/components/schemas/AssetTypeEnum"
|
||||
},
|
||||
"lat": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"lon": {
|
||||
"type": "number",
|
||||
"format": "double"
|
||||
},
|
||||
"id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"lat",
|
||||
"lon",
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"UpdateAssetDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -12,6 +12,16 @@ export interface LivePhotoSearchOptions {
|
||||
type: AssetType;
|
||||
}
|
||||
|
||||
export interface MapMarkerSearchOptions {
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
|
||||
export interface MapMarker {
|
||||
id: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
}
|
||||
|
||||
export enum WithoutProperty {
|
||||
THUMBNAIL = 'thumbnail',
|
||||
ENCODED_VIDEO = 'encoded-video',
|
||||
@@ -31,4 +41,5 @@ export interface IAssetRepository {
|
||||
getAll(options?: AssetSearchOptions): Promise<AssetEntity[]>;
|
||||
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
|
||||
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
|
||||
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { assetEntityStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
|
||||
import { assetEntityStub, authStub, newAssetRepositoryMock, newJobRepositoryMock } from '../../test';
|
||||
import { AssetService, IAssetRepository } from '../asset';
|
||||
import { IJobRepository, JobName } from '../job';
|
||||
|
||||
@@ -58,4 +58,29 @@ describe(AssetService.name, () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get map markers', () => {
|
||||
it('should get geo information of assets', async () => {
|
||||
assetMock.getMapMarkers.mockResolvedValue(
|
||||
[assetEntityStub.withLocation].map((asset) => ({
|
||||
id: asset.id,
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
})),
|
||||
);
|
||||
|
||||
const markers = await sut.getMapMarkers(authStub.user1, {});
|
||||
|
||||
expect(markers).toHaveLength(1);
|
||||
expect(markers[0]).toEqual({
|
||||
id: assetEntityStub.withLocation.id,
|
||||
lat: 100,
|
||||
lon: 100,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IAssetUploadedJob, IJobRepository, JobName } from '../job';
|
||||
import { AssetCore } from './asset.core';
|
||||
import { IAssetRepository } from './asset.repository';
|
||||
import { MapMarkerDto } from './dto/map-marker.dto';
|
||||
import { MapMarkerResponseDto } from './response-dto';
|
||||
|
||||
export class AssetService {
|
||||
private assetCore: AssetCore;
|
||||
|
||||
constructor(
|
||||
@Inject(IAssetRepository) assetRepository: IAssetRepository,
|
||||
@Inject(IAssetRepository) private assetRepository: IAssetRepository,
|
||||
@Inject(IJobRepository) private jobRepository: IJobRepository,
|
||||
) {
|
||||
this.assetCore = new AssetCore(assetRepository, jobRepository);
|
||||
@@ -28,4 +31,8 @@ export class AssetService {
|
||||
save(asset: Partial<AssetEntity>) {
|
||||
return this.assetCore.save(asset);
|
||||
}
|
||||
|
||||
getMapMarkers(authUser: AuthUserDto, options: MapMarkerDto): Promise<MapMarkerResponseDto[]> {
|
||||
return this.assetRepository.getMapMarkers(authUser.id, options);
|
||||
}
|
||||
}
|
||||
|
||||
10
server/libs/domain/src/asset/dto/map-marker.dto.ts
Normal file
10
server/libs/domain/src/asset/dto/map-marker.dto.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { toBoolean } from 'apps/immich/src/utils/transform.util';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsOptional } from 'class-validator';
|
||||
|
||||
export class MapMarkerDto {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(toBoolean)
|
||||
isFavorite?: boolean;
|
||||
}
|
||||
@@ -1,35 +1,12 @@
|
||||
import { AssetEntity, AssetType } from '@app/infra/entities';
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class MapMarkerResponseDto {
|
||||
@ApiProperty()
|
||||
id!: string;
|
||||
|
||||
@ApiProperty({ enumName: 'AssetTypeEnum', enum: AssetType })
|
||||
type!: AssetType;
|
||||
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ format: 'double' })
|
||||
lat!: number;
|
||||
|
||||
@ApiProperty({ type: 'number', format: 'double' })
|
||||
@ApiProperty({ format: 'double' })
|
||||
lon!: number;
|
||||
}
|
||||
|
||||
export function mapAssetMapMarker(entity: AssetEntity): MapMarkerResponseDto | null {
|
||||
if (!entity.exifInfo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lat = entity.exifInfo.latitude;
|
||||
const lon = entity.exifInfo.longitude;
|
||||
|
||||
if (!lat || !lon) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: entity.id,
|
||||
type: entity.type,
|
||||
lon,
|
||||
lat,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,5 +9,6 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
|
||||
deleteAll: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findLivePhotoMatch: jest.fn(),
|
||||
getMapMarkers: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
UserEntity,
|
||||
UserTokenEntity,
|
||||
AssetFaceEntity,
|
||||
ExifEntity,
|
||||
} from '@app/infra/entities';
|
||||
import {
|
||||
AlbumResponseDto,
|
||||
@@ -220,6 +221,38 @@ export const assetEntityStub = {
|
||||
fileModifiedAt: '2022-06-19T23:41:36.910Z',
|
||||
fileCreatedAt: '2022-06-19T23:41:36.910Z',
|
||||
} as AssetEntity),
|
||||
|
||||
withLocation: Object.freeze<AssetEntity>({
|
||||
id: 'asset-with-favorite-id',
|
||||
deviceAssetId: 'device-asset-id',
|
||||
fileModifiedAt: '2023-02-23T05:06:29.716Z',
|
||||
fileCreatedAt: '2023-02-23T05:06:29.716Z',
|
||||
owner: userEntityStub.user1,
|
||||
ownerId: 'user-id',
|
||||
deviceId: 'device-id',
|
||||
originalPath: '/original/path.ext',
|
||||
resizePath: '/uploads/user-id/thumbs/path.ext',
|
||||
type: AssetType.IMAGE,
|
||||
webpPath: null,
|
||||
encodedVideoPath: null,
|
||||
createdAt: '2023-02-23T05:06:29.716Z',
|
||||
updatedAt: '2023-02-23T05:06:29.716Z',
|
||||
mimeType: null,
|
||||
isFavorite: false,
|
||||
isArchived: false,
|
||||
duration: null,
|
||||
isVisible: true,
|
||||
livePhotoVideo: null,
|
||||
livePhotoVideoId: null,
|
||||
tags: [],
|
||||
sharedLinks: [],
|
||||
originalFileName: 'asset-id.ext',
|
||||
faces: [],
|
||||
exifInfo: {
|
||||
latitude: 100,
|
||||
longitude: 100,
|
||||
} as ExifEntity,
|
||||
}),
|
||||
};
|
||||
|
||||
export const albumStub = {
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { AssetSearchOptions, IAssetRepository, LivePhotoSearchOptions, WithoutProperty } from '@app/domain';
|
||||
import {
|
||||
AssetSearchOptions,
|
||||
IAssetRepository,
|
||||
LivePhotoSearchOptions,
|
||||
MapMarker,
|
||||
MapMarkerSearchOptions,
|
||||
WithoutProperty,
|
||||
} from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
|
||||
@@ -21,7 +28,6 @@ export class AssetRepository implements IAssetRepository {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteAll(ownerId: string): Promise<void> {
|
||||
await this.repository.delete({ ownerId });
|
||||
}
|
||||
@@ -166,4 +172,44 @@ export class AssetRepository implements IAssetRepository {
|
||||
order: { fileCreatedAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async getMapMarkers(ownerId: string, options: MapMarkerSearchOptions = {}): Promise<MapMarker[]> {
|
||||
const { isFavorite } = options;
|
||||
|
||||
const assets = await this.repository.find({
|
||||
select: {
|
||||
id: true,
|
||||
exifInfo: {
|
||||
latitude: true,
|
||||
longitude: true,
|
||||
},
|
||||
},
|
||||
where: {
|
||||
ownerId,
|
||||
isVisible: true,
|
||||
isArchived: false,
|
||||
exifInfo: {
|
||||
latitude: Not(IsNull()),
|
||||
longitude: Not(IsNull()),
|
||||
},
|
||||
isFavorite,
|
||||
},
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
},
|
||||
order: {
|
||||
fileCreatedAt: 'DESC',
|
||||
},
|
||||
});
|
||||
|
||||
return assets.map((asset) => ({
|
||||
id: asset.id,
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
lat: asset.exifInfo!.latitude!,
|
||||
|
||||
/* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */
|
||||
lon: asset.exifInfo!.longitude!,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user