1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-25 10:43:13 +02:00

fix(server): memory lane title (#2772)

* fix(server): memory lane title

* feat: parallel requests

* pr feedback

* fix test

---------

Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Jason Rasmussen 2023-06-15 14:05:30 -04:00 committed by GitHub
parent 045bb855d2
commit 896645130b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 123 additions and 54 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1539,10 +1539,12 @@
"operationId": "getMemoryLane", "operationId": "getMemoryLane",
"parameters": [ "parameters": [
{ {
"name": "timezone", "name": "timestamp",
"required": true, "required": true,
"in": "query", "in": "query",
"description": "Get pictures for +24 hours from this time going back x years",
"schema": { "schema": {
"format": "date-time",
"type": "string" "type": "string"
} }
} }

View File

@ -1,5 +1,6 @@
import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test'; import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test';
import { AssetService, IAssetRepository } from '.'; import { when } from 'jest-when';
import { AssetService, IAssetRepository, mapAsset } from '.';
describe(AssetService.name, () => { describe(AssetService.name, () => {
let sut: AssetService; let sut: AssetService;
@ -38,4 +39,62 @@ describe(AssetService.name, () => {
}); });
}); });
}); });
describe('getMemoryLane', () => {
it('should get pictures for each year', async () => {
assetMock.getByDate.mockResolvedValue([]);
await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 10 })).resolves.toEqual(
[],
);
expect(assetMock.getByDate).toHaveBeenCalledTimes(10);
expect(assetMock.getByDate.mock.calls).toEqual([
[authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2020-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2019-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2018-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2017-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2016-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2015-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2014-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2013-06-15T00:00:00.000Z')],
]);
});
it('should keep hours from the date', async () => {
assetMock.getByDate.mockResolvedValue([]);
await expect(
sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15, 5), years: 2 }),
).resolves.toEqual([]);
expect(assetMock.getByDate).toHaveBeenCalledTimes(2);
expect(assetMock.getByDate.mock.calls).toEqual([
[authStub.admin.id, new Date('2022-06-15T05:00:00.000Z')],
[authStub.admin.id, new Date('2021-06-15T05:00:00.000Z')],
]);
});
});
it('should set the title correctly', async () => {
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z'))
.mockResolvedValue([assetEntityStub.image]);
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z'))
.mockResolvedValue([assetEntityStub.video]);
await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([
{ title: '1 year since...', assets: [mapAsset(assetEntityStub.image)] },
{ title: '2 years since...', assets: [mapAsset(assetEntityStub.video)] },
]);
expect(assetMock.getByDate).toHaveBeenCalledTimes(2);
expect(assetMock.getByDate.mock.calls).toEqual([
[authStub.admin.id, new Date('2022-06-15T00:00:00.000Z')],
[authStub.admin.id, new Date('2021-06-15T00:00:00.000Z')],
]);
});
}); });

View File

@ -1,10 +1,11 @@
import { Inject } from '@nestjs/common'; import { Inject } from '@nestjs/common';
import { DateTime } from 'luxon';
import { AuthUserDto } from '../auth'; import { AuthUserDto } from '../auth';
import { IAssetRepository } from './asset.repository'; import { IAssetRepository } from './asset.repository';
import { MemoryLaneDto } from './dto';
import { MapMarkerDto } from './dto/map-marker.dto'; import { MapMarkerDto } from './dto/map-marker.dto';
import { MapMarkerResponseDto, mapAsset } from './response-dto'; import { mapAsset, MapMarkerResponseDto } from './response-dto';
import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto'; import { MemoryLaneResponseDto } from './response-dto/memory-lane-response.dto';
import { DateTime } from 'luxon';
export class AssetService { export class AssetService {
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {} constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
@ -13,30 +14,22 @@ export class AssetService {
return this.assetRepository.getMapMarkers(authUser.id, options); return this.assetRepository.getMapMarkers(authUser.id, options);
} }
async getMemoryLane(authUser: AuthUserDto, timezone: string): Promise<MemoryLaneResponseDto[]> { async getMemoryLane(authUser: AuthUserDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
const result: MemoryLaneResponseDto[] = []; const target = DateTime.fromJSDate(dto.timestamp);
const luxonDate = DateTime.fromISO(new Date().toISOString(), { zone: timezone }); const onRequest = async (yearsAgo: number): Promise<MemoryLaneResponseDto> => {
const today = new Date(luxonDate.year, luxonDate.month - 1, luxonDate.day); const assets = await this.assetRepository.getByDate(authUser.id, target.minus({ years: yearsAgo }).toJSDate());
return {
title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} since...`,
assets: assets.map((a) => mapAsset(a)),
};
};
const years = Array.from({ length: 30 }, (_, i) => { const requests: Promise<MemoryLaneResponseDto>[] = [];
const year = today.getFullYear() - i - 1; for (let i = 1; i <= dto.years; i++) {
return new Date(year, today.getMonth(), today.getDate()); requests.push(onRequest(i));
});
for (const year of years) {
const assets = await this.assetRepository.getByDate(authUser.id, year);
if (assets.length > 0) {
const yearGap = today.getFullYear() - year.getFullYear();
const memory = new MemoryLaneResponseDto();
memory.title = `${yearGap} year${yearGap > 1 && 's'} since...`;
memory.assets = assets.map((a) => mapAsset(a));
result.push(memory);
}
} }
return result; return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0));
} }
} }

View File

@ -1,2 +1,3 @@
export * from './asset-ids.dto'; export * from './asset-ids.dto';
export * from './map-marker.dto'; export * from './map-marker.dto';
export * from './memory-lane.dto';

View File

@ -0,0 +1,14 @@
import { Type } from 'class-transformer';
import { IsDate, IsNumber, IsPositive } from 'class-validator';
export class MemoryLaneDto {
/** Get pictures for +24 hours from this time going back x years */
@IsDate()
@Type(() => Date)
timestamp!: Date;
@IsNumber()
@IsPositive()
@Type(() => Number)
years = 30;
}

View File

@ -2,6 +2,5 @@ import { AssetResponseDto } from './asset-response.dto';
export class MemoryLaneResponseDto { export class MemoryLaneResponseDto {
title!: string; title!: string;
assets!: AssetResponseDto[]; assets!: AssetResponseDto[];
} }

View File

@ -1,4 +1,4 @@
import { AssetService, AuthUserDto, MapMarkerResponseDto } from '@app/domain'; import { AssetService, AuthUserDto, MapMarkerResponseDto, MemoryLaneDto } from '@app/domain';
import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto'; import { MapMarkerDto } from '@app/domain/asset/dto/map-marker.dto';
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
@ -20,10 +20,7 @@ export class AssetController {
} }
@Get('memory-lane') @Get('memory-lane')
getMemoryLane( getMemoryLane(@GetAuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
@GetAuthUser() authUser: AuthUserDto, return this.service.getMemoryLane(authUser, dto);
@Query('timezone') timezone: string,
): Promise<MemoryLaneResponseDto[]> {
return this.service.getMemoryLane(authUser, timezone);
} }
} }

View File

@ -11,6 +11,7 @@ import {
} from '@app/domain'; } from '@app/domain';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { DateTime } from 'luxon';
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm'; import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
import { AssetEntity, AssetType } from '../entities'; import { AssetEntity, AssetType } from '../entities';
import OptionalBetween from '../utils/optional-between.util'; import OptionalBetween from '../utils/optional-between.util';
@ -21,7 +22,7 @@ export class AssetRepository implements IAssetRepository {
constructor(@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>) {} constructor(@InjectRepository(AssetEntity) private repository: Repository<AssetEntity>) {}
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> { getByDate(ownerId: string, date: Date): Promise<AssetEntity[]> {
// For reference of a correct approach althought slower // For reference of a correct approach although slower
// let builder = this.repository // let builder = this.repository
// .createQueryBuilder('asset') // .createQueryBuilder('asset')
@ -36,14 +37,13 @@ export class AssetRepository implements IAssetRepository {
// .orderBy('asset.fileCreatedAt', 'DESC'); // .orderBy('asset.fileCreatedAt', 'DESC');
// return builder.getMany(); // return builder.getMany();
const tomorrow = new Date(date.getTime() + 24 * 60 * 60 * 1000);
return this.repository.find({ return this.repository.find({
where: { where: {
ownerId, ownerId,
isVisible: true, isVisible: true,
isArchived: false, isArchived: false,
fileCreatedAt: OptionalBetween(date, tomorrow), fileCreatedAt: OptionalBetween(date, DateTime.fromJSDate(date).plus({ day: 1 }).toJSDate()),
}, },
relations: { relations: {
exifInfo: true, exifInfo: true,

View File

@ -5523,13 +5523,13 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
}, },
/** /**
* *
* @param {string} timezone * @param {string} timestamp Get pictures for +24 hours from this time going back x years
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getMemoryLane: async (timezone: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => { getMemoryLane: async (timestamp: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'timezone' is not null or undefined // verify required parameter 'timestamp' is not null or undefined
assertParamExists('getMemoryLane', 'timezone', timezone) assertParamExists('getMemoryLane', 'timestamp', timestamp)
const localVarPath = `/asset/memory-lane`; const localVarPath = `/asset/memory-lane`;
// use dummy base URL string because the URL constructor only accepts absolute URLs. // use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -5551,8 +5551,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required // http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration) await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (timezone !== undefined) { if (timestamp !== undefined) {
localVarQueryParameter['timezone'] = timezone; localVarQueryParameter['timestamp'] = (timestamp as any instanceof Date) ?
(timestamp as any).toISOString() :
timestamp;
} }
@ -6157,12 +6159,12 @@ export const AssetApiFp = function(configuration?: Configuration) {
}, },
/** /**
* *
* @param {string} timezone * @param {string} timestamp Get pictures for +24 hours from this time going back x years
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
async getMemoryLane(timezone: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> { async getMemoryLane(timestamp: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timezone, options); const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration); return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
}, },
/** /**
@ -6446,12 +6448,12 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
}, },
/** /**
* *
* @param {string} timezone * @param {string} timestamp Get pictures for +24 hours from this time going back x years
* @param {*} [options] Override http request option. * @param {*} [options] Override http request option.
* @throws {RequiredError} * @throws {RequiredError}
*/ */
getMemoryLane(timezone: string, options?: any): AxiosPromise<Array<MemoryLaneResponseDto>> { getMemoryLane(timestamp: string, options?: any): AxiosPromise<Array<MemoryLaneResponseDto>> {
return localVarFp.getMemoryLane(timezone, options).then((request) => request(axios, basePath)); return localVarFp.getMemoryLane(timestamp, options).then((request) => request(axios, basePath));
}, },
/** /**
* Get all asset of a device that are in the database, ID only. * Get all asset of a device that are in the database, ID only.
@ -6857,11 +6859,11 @@ export interface AssetApiGetMapMarkersRequest {
*/ */
export interface AssetApiGetMemoryLaneRequest { export interface AssetApiGetMemoryLaneRequest {
/** /**
* * Get pictures for +24 hours from this time going back x years
* @type {string} * @type {string}
* @memberof AssetApiGetMemoryLane * @memberof AssetApiGetMemoryLane
*/ */
readonly timezone: string readonly timestamp: string
} }
/** /**
@ -7304,7 +7306,7 @@ export class AssetApi extends BaseAPI {
* @memberof AssetApi * @memberof AssetApi
*/ */
public getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig) { public getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timezone, options).then((request) => request(this.axios, this.basePath)); return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath));
} }
/** /**

View File

@ -35,8 +35,9 @@
onMount(async () => { onMount(async () => {
if (!$memoryStore) { if (!$memoryStore) {
const timezone = DateTime.local().zoneName; const { data } = await api.assetApi.getMemoryLane({
const { data } = await api.assetApi.getMemoryLane({ timezone }); timestamp: DateTime.local().startOf('day').toISO()
});
$memoryStore = data; $memoryStore = data;
} }

View File

@ -11,8 +11,9 @@
$: shouldRender = memoryLane.length > 0; $: shouldRender = memoryLane.length > 0;
onMount(async () => { onMount(async () => {
const timezone = DateTime.local().zoneName; const { data } = await api.assetApi.getMemoryLane({
const { data } = await api.assetApi.getMemoryLane({ timezone }); timestamp: DateTime.local().startOf('day').toISO()
});
memoryLane = data; memoryLane = data;
$memoryStore = data; $memoryStore = data;