mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00: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:
parent
045bb855d2
commit
896645130b
14 changed files with 123 additions and 54 deletions
BIN
mobile/openapi/doc/AssetApi.md
generated
BIN
mobile/openapi/doc/AssetApi.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/api/asset_api.dart
generated
BIN
mobile/openapi/lib/api/asset_api.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_api_test.dart
generated
BIN
mobile/openapi/test/asset_api_test.dart
generated
Binary file not shown.
|
@ -1539,10 +1539,12 @@
|
|||
"operationId": "getMemoryLane",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "timezone",
|
||||
"name": "timestamp",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"description": "Get pictures for +24 hours from this time going back x years",
|
||||
"schema": {
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { assetEntityStub, authStub, newAssetRepositoryMock } from '@test';
|
||||
import { AssetService, IAssetRepository } from '.';
|
||||
import { when } from 'jest-when';
|
||||
import { AssetService, IAssetRepository, mapAsset } from '.';
|
||||
|
||||
describe(AssetService.name, () => {
|
||||
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')],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import { Inject } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { AuthUserDto } from '../auth';
|
||||
import { IAssetRepository } from './asset.repository';
|
||||
import { MemoryLaneDto } from './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 { DateTime } from 'luxon';
|
||||
|
||||
export class AssetService {
|
||||
constructor(@Inject(IAssetRepository) private assetRepository: IAssetRepository) {}
|
||||
|
@ -13,30 +14,22 @@ export class AssetService {
|
|||
return this.assetRepository.getMapMarkers(authUser.id, options);
|
||||
}
|
||||
|
||||
async getMemoryLane(authUser: AuthUserDto, timezone: string): Promise<MemoryLaneResponseDto[]> {
|
||||
const result: MemoryLaneResponseDto[] = [];
|
||||
async getMemoryLane(authUser: AuthUserDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
||||
const target = DateTime.fromJSDate(dto.timestamp);
|
||||
|
||||
const luxonDate = DateTime.fromISO(new Date().toISOString(), { zone: timezone });
|
||||
const today = new Date(luxonDate.year, luxonDate.month - 1, luxonDate.day);
|
||||
const onRequest = async (yearsAgo: number): Promise<MemoryLaneResponseDto> => {
|
||||
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 year = today.getFullYear() - i - 1;
|
||||
return new Date(year, today.getMonth(), today.getDate());
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
const requests: Promise<MemoryLaneResponseDto>[] = [];
|
||||
for (let i = 1; i <= dto.years; i++) {
|
||||
requests.push(onRequest(i));
|
||||
}
|
||||
|
||||
return result;
|
||||
return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,2 +1,3 @@
|
|||
export * from './asset-ids.dto';
|
||||
export * from './map-marker.dto';
|
||||
export * from './memory-lane.dto';
|
||||
|
|
14
server/src/domain/asset/dto/memory-lane.dto.ts
Normal file
14
server/src/domain/asset/dto/memory-lane.dto.ts
Normal 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;
|
||||
}
|
|
@ -2,6 +2,5 @@ import { AssetResponseDto } from './asset-response.dto';
|
|||
|
||||
export class MemoryLaneResponseDto {
|
||||
title!: string;
|
||||
|
||||
assets!: AssetResponseDto[];
|
||||
}
|
||||
|
|
|
@ -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 { Controller, Get, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
|
@ -20,10 +20,7 @@ export class AssetController {
|
|||
}
|
||||
|
||||
@Get('memory-lane')
|
||||
getMemoryLane(
|
||||
@GetAuthUser() authUser: AuthUserDto,
|
||||
@Query('timezone') timezone: string,
|
||||
): Promise<MemoryLaneResponseDto[]> {
|
||||
return this.service.getMemoryLane(authUser, timezone);
|
||||
getMemoryLane(@GetAuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
|
||||
return this.service.getMemoryLane(authUser, dto);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import {
|
|||
} from '@app/domain';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DateTime } from 'luxon';
|
||||
import { FindOptionsRelations, FindOptionsWhere, In, IsNull, Not, Repository } from 'typeorm';
|
||||
import { AssetEntity, AssetType } from '../entities';
|
||||
import OptionalBetween from '../utils/optional-between.util';
|
||||
|
@ -21,7 +22,7 @@ export class AssetRepository implements IAssetRepository {
|
|||
constructor(@InjectRepository(AssetEntity) private repository: Repository<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
|
||||
// .createQueryBuilder('asset')
|
||||
|
@ -36,14 +37,13 @@ export class AssetRepository implements IAssetRepository {
|
|||
// .orderBy('asset.fileCreatedAt', 'DESC');
|
||||
|
||||
// return builder.getMany();
|
||||
const tomorrow = new Date(date.getTime() + 24 * 60 * 60 * 1000);
|
||||
|
||||
return this.repository.find({
|
||||
where: {
|
||||
ownerId,
|
||||
isVisible: true,
|
||||
isArchived: false,
|
||||
fileCreatedAt: OptionalBetween(date, tomorrow),
|
||||
fileCreatedAt: OptionalBetween(date, DateTime.fromJSDate(date).plus({ day: 1 }).toJSDate()),
|
||||
},
|
||||
relations: {
|
||||
exifInfo: true,
|
||||
|
|
32
web/src/api/open-api/api.ts
generated
32
web/src/api/open-api/api.ts
generated
|
@ -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.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMemoryLane: async (timezone: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'timezone' is not null or undefined
|
||||
assertParamExists('getMemoryLane', 'timezone', timezone)
|
||||
getMemoryLane: async (timestamp: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
|
||||
// verify required parameter 'timestamp' is not null or undefined
|
||||
assertParamExists('getMemoryLane', 'timestamp', timestamp)
|
||||
const localVarPath = `/asset/memory-lane`;
|
||||
// use dummy base URL string because the URL constructor only accepts absolute URLs.
|
||||
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
|
||||
|
@ -5551,8 +5551,10 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
|
|||
// http bearer authentication required
|
||||
await setBearerAuthToObject(localVarHeaderParameter, configuration)
|
||||
|
||||
if (timezone !== undefined) {
|
||||
localVarQueryParameter['timezone'] = timezone;
|
||||
if (timestamp !== undefined) {
|
||||
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.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
async getMemoryLane(timezone: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timezone, options);
|
||||
async getMemoryLane(timestamp: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
|
||||
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
|
||||
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.
|
||||
* @throws {RequiredError}
|
||||
*/
|
||||
getMemoryLane(timezone: string, options?: any): AxiosPromise<Array<MemoryLaneResponseDto>> {
|
||||
return localVarFp.getMemoryLane(timezone, options).then((request) => request(axios, basePath));
|
||||
getMemoryLane(timestamp: string, options?: any): AxiosPromise<Array<MemoryLaneResponseDto>> {
|
||||
return localVarFp.getMemoryLane(timestamp, options).then((request) => request(axios, basePath));
|
||||
},
|
||||
/**
|
||||
* Get all asset of a device that are in the database, ID only.
|
||||
|
@ -6857,11 +6859,11 @@ export interface AssetApiGetMapMarkersRequest {
|
|||
*/
|
||||
export interface AssetApiGetMemoryLaneRequest {
|
||||
/**
|
||||
*
|
||||
* Get pictures for +24 hours from this time going back x years
|
||||
* @type {string}
|
||||
* @memberof AssetApiGetMemoryLane
|
||||
*/
|
||||
readonly timezone: string
|
||||
readonly timestamp: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -7304,7 +7306,7 @@ export class AssetApi extends BaseAPI {
|
|||
* @memberof AssetApi
|
||||
*/
|
||||
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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -35,8 +35,9 @@
|
|||
|
||||
onMount(async () => {
|
||||
if (!$memoryStore) {
|
||||
const timezone = DateTime.local().zoneName;
|
||||
const { data } = await api.assetApi.getMemoryLane({ timezone });
|
||||
const { data } = await api.assetApi.getMemoryLane({
|
||||
timestamp: DateTime.local().startOf('day').toISO()
|
||||
});
|
||||
$memoryStore = data;
|
||||
}
|
||||
|
||||
|
|
|
@ -11,8 +11,9 @@
|
|||
$: shouldRender = memoryLane.length > 0;
|
||||
|
||||
onMount(async () => {
|
||||
const timezone = DateTime.local().zoneName;
|
||||
const { data } = await api.assetApi.getMemoryLane({ timezone });
|
||||
const { data } = await api.assetApi.getMemoryLane({
|
||||
timestamp: DateTime.local().startOf('day').toISO()
|
||||
});
|
||||
|
||||
memoryLane = data;
|
||||
$memoryStore = data;
|
||||
|
|
Loading…
Reference in a new issue