diff --git a/mobile/openapi/doc/AssetApi.md b/mobile/openapi/doc/AssetApi.md index a27740f95c..b40916e1f3 100644 Binary files a/mobile/openapi/doc/AssetApi.md and b/mobile/openapi/doc/AssetApi.md differ diff --git a/mobile/openapi/lib/api/asset_api.dart b/mobile/openapi/lib/api/asset_api.dart index 9646640c6b..188d239b10 100644 Binary files a/mobile/openapi/lib/api/asset_api.dart and b/mobile/openapi/lib/api/asset_api.dart differ diff --git a/mobile/openapi/test/asset_api_test.dart b/mobile/openapi/test/asset_api_test.dart index fdc38c14d8..9a77c292b6 100644 Binary files a/mobile/openapi/test/asset_api_test.dart and b/mobile/openapi/test/asset_api_test.dart differ diff --git a/server/immich-openapi-specs.json b/server/immich-openapi-specs.json index 1f88295bf7..18702ea287 100644 --- a/server/immich-openapi-specs.json +++ b/server/immich-openapi-specs.json @@ -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" } } diff --git a/server/src/domain/asset/asset.service.spec.ts b/server/src/domain/asset/asset.service.spec.ts index b3ffd76680..b6f2531138 100644 --- a/server/src/domain/asset/asset.service.spec.ts +++ b/server/src/domain/asset/asset.service.spec.ts @@ -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')], + ]); + }); }); diff --git a/server/src/domain/asset/asset.service.ts b/server/src/domain/asset/asset.service.ts index 3f86a7930e..230192e112 100644 --- a/server/src/domain/asset/asset.service.ts +++ b/server/src/domain/asset/asset.service.ts @@ -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 { - const result: MemoryLaneResponseDto[] = []; + async getMemoryLane(authUser: AuthUserDto, dto: MemoryLaneDto): Promise { + 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 => { + 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[] = []; + 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)); } } diff --git a/server/src/domain/asset/dto/index.ts b/server/src/domain/asset/dto/index.ts index 900b25d285..130f28144a 100644 --- a/server/src/domain/asset/dto/index.ts +++ b/server/src/domain/asset/dto/index.ts @@ -1,2 +1,3 @@ export * from './asset-ids.dto'; export * from './map-marker.dto'; +export * from './memory-lane.dto'; diff --git a/server/src/domain/asset/dto/memory-lane.dto.ts b/server/src/domain/asset/dto/memory-lane.dto.ts new file mode 100644 index 0000000000..8309a73cad --- /dev/null +++ b/server/src/domain/asset/dto/memory-lane.dto.ts @@ -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; +} diff --git a/server/src/domain/asset/response-dto/memory-lane-response.dto.ts b/server/src/domain/asset/response-dto/memory-lane-response.dto.ts index f855579435..875a0d5b75 100644 --- a/server/src/domain/asset/response-dto/memory-lane-response.dto.ts +++ b/server/src/domain/asset/response-dto/memory-lane-response.dto.ts @@ -2,6 +2,5 @@ import { AssetResponseDto } from './asset-response.dto'; export class MemoryLaneResponseDto { title!: string; - assets!: AssetResponseDto[]; } diff --git a/server/src/immich/controllers/asset.controller.ts b/server/src/immich/controllers/asset.controller.ts index c3fbdbbabf..62e132acf6 100644 --- a/server/src/immich/controllers/asset.controller.ts +++ b/server/src/immich/controllers/asset.controller.ts @@ -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 { - return this.service.getMemoryLane(authUser, timezone); + getMemoryLane(@GetAuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise { + return this.service.getMemoryLane(authUser, dto); } } diff --git a/server/src/infra/repositories/asset.repository.ts b/server/src/infra/repositories/asset.repository.ts index 042f3e4e33..0dc3704746 100644 --- a/server/src/infra/repositories/asset.repository.ts +++ b/server/src/infra/repositories/asset.repository.ts @@ -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) {} getByDate(ownerId: string, date: Date): Promise { - // 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, diff --git a/web/src/api/open-api/api.ts b/web/src/api/open-api/api.ts index b30ebfd237..e14afca491 100644 --- a/web/src/api/open-api/api.ts +++ b/web/src/api/open-api/api.ts @@ -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 => { - // verify required parameter 'timezone' is not null or undefined - assertParamExists('getMemoryLane', 'timezone', timezone) + getMemoryLane: async (timestamp: string, options: AxiosRequestConfig = {}): Promise => { + // 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>> { - const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timezone, options); + async getMemoryLane(timestamp: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise>> { + 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> { - return localVarFp.getMemoryLane(timezone, options).then((request) => request(axios, basePath)); + getMemoryLane(timestamp: string, options?: any): AxiosPromise> { + 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)); } /** diff --git a/web/src/lib/components/memory-page/memory-viewer.svelte b/web/src/lib/components/memory-page/memory-viewer.svelte index 65716dd070..5e2651c001 100644 --- a/web/src/lib/components/memory-page/memory-viewer.svelte +++ b/web/src/lib/components/memory-page/memory-viewer.svelte @@ -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; } diff --git a/web/src/lib/components/photos-page/memory-lane.svelte b/web/src/lib/components/photos-page/memory-lane.svelte index 873f483415..17972845da 100644 --- a/web/src/lib/components/photos-page/memory-lane.svelte +++ b/web/src/lib/components/photos-page/memory-lane.svelte @@ -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;