Browse Source

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>
Jason Rasmussen 2 years ago
parent
commit
896645130b

+ 4 - 4
mobile/openapi/doc/AssetApi.md

@@ -1163,7 +1163,7 @@ Name | Type | Description  | Notes
 [[Back to top]](#) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to Model list]](../README.md#documentation-for-models) [[Back to README]](../README.md)
 
 # **getMemoryLane**
-> List<MemoryLaneResponseDto> getMemoryLane(timezone)
+> List<MemoryLaneResponseDto> getMemoryLane(timestamp)
 
 
 
@@ -1186,10 +1186,10 @@ import 'package:openapi/api.dart';
 //defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
 
 final api_instance = AssetApi();
-final timezone = timezone_example; // String | 
+final timestamp = 2013-10-20T19:20:30+01:00; // DateTime | Get pictures for +24 hours from this time going back x years
 
 try {
-    final result = api_instance.getMemoryLane(timezone);
+    final result = api_instance.getMemoryLane(timestamp);
     print(result);
 } catch (e) {
     print('Exception when calling AssetApi->getMemoryLane: $e\n');
@@ -1200,7 +1200,7 @@ try {
 
 Name | Type | Description  | Notes
 ------------- | ------------- | ------------- | -------------
- **timezone** | **String**|  | 
+ **timestamp** | **DateTime**| Get pictures for +24 hours from this time going back x years | 
 
 ### Return type
 

+ 8 - 6
mobile/openapi/lib/api/asset_api.dart

@@ -1118,8 +1118,9 @@ class AssetApi {
   /// Performs an HTTP 'GET /asset/memory-lane' operation and returns the [Response].
   /// Parameters:
   ///
-  /// * [String] timezone (required):
-  Future<Response> getMemoryLaneWithHttpInfo(String timezone,) async {
+  /// * [DateTime] timestamp (required):
+  ///   Get pictures for +24 hours from this time going back x years
+  Future<Response> getMemoryLaneWithHttpInfo(DateTime timestamp,) async {
     // ignore: prefer_const_declarations
     final path = r'/asset/memory-lane';
 
@@ -1130,7 +1131,7 @@ class AssetApi {
     final headerParams = <String, String>{};
     final formParams = <String, String>{};
 
-      queryParams.addAll(_queryParams('', 'timezone', timezone));
+      queryParams.addAll(_queryParams('', 'timestamp', timestamp));
 
     const contentTypes = <String>[];
 
@@ -1148,9 +1149,10 @@ class AssetApi {
 
   /// Parameters:
   ///
-  /// * [String] timezone (required):
-  Future<List<MemoryLaneResponseDto>?> getMemoryLane(String timezone,) async {
-    final response = await getMemoryLaneWithHttpInfo(timezone,);
+  /// * [DateTime] timestamp (required):
+  ///   Get pictures for +24 hours from this time going back x years
+  Future<List<MemoryLaneResponseDto>?> getMemoryLane(DateTime timestamp,) async {
+    final response = await getMemoryLaneWithHttpInfo(timestamp,);
     if (response.statusCode >= HttpStatus.badRequest) {
       throw ApiException(response.statusCode, await _decodeBodyBytes(response));
     }

+ 1 - 1
mobile/openapi/test/asset_api_test.dart

@@ -129,7 +129,7 @@ void main() {
       // TODO
     });
 
-    //Future<List<MemoryLaneResponseDto>> getMemoryLane(String timezone) async
+    //Future<List<MemoryLaneResponseDto>> getMemoryLane(DateTime timestamp) async
     test('test getMemoryLane', () async {
       // TODO
     });

+ 3 - 1
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"
             }
           }

+ 60 - 1
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')],
+    ]);
+  });
 });

+ 16 - 23
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<MemoryLaneResponseDto[]> {
-    const result: MemoryLaneResponseDto[] = [];
-
-    const luxonDate = DateTime.fromISO(new Date().toISOString(), { zone: timezone });
-    const today = new Date(luxonDate.year, luxonDate.month - 1, luxonDate.day);
-
-    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);
+  async getMemoryLane(authUser: AuthUserDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
+    const target = DateTime.fromJSDate(dto.timestamp);
 
-      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));
+    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)),
+      };
+    };
 
-        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 - 0
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';

+ 14 - 0
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;
+}

+ 0 - 1
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[];
 }

+ 3 - 6
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<MemoryLaneResponseDto[]> {
-    return this.service.getMemoryLane(authUser, timezone);
+  getMemoryLane(@GetAuthUser() authUser: AuthUserDto, @Query() dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
+    return this.service.getMemoryLane(authUser, dto);
   }
 }

+ 3 - 3
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<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,

+ 17 - 15
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<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));
     }
 
     /**

+ 3 - 2
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;
 		}
 

+ 3 - 2
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;