0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-21 00:52:43 -05:00

fix: use local time for time buckets and improve memories (#4072)

* fix: timezone bucket timezones

* chore: open api

* fix: interpret local time in utc

* fix: tests

* fix: refactor memory lane

* fix(web): use local date in memory viewer

* chore: set localDateTime non-null

* fix: filter out memories from the current year

* wip: move localDateTime to asset

* fix: correct sorting from db

* fix: migration

* fix: web typo

* fix: formatting

* fix: e2e

* chore: localDateTime is non-null

* chore: more non-nulliness

* fix: asset stub

* fix: tests

* fix: use extract and index for day of year

* fix: don't show memories before today

* fix: cleanup

* fix: tests

* fix: only use localtime for tz

* fix: display memories in client timezone

* fix: tests

* fix: svelte tests

* fix: bugs

* chore: open api

---------

Co-authored-by: Jonathan Jogenfors <jonathan@jogenfors.se>
This commit is contained in:
Jason Rasmussen 2023-10-04 18:11:11 -04:00 committed by GitHub
parent 126dd45751
commit 192e950567
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 337 additions and 147 deletions

View file

@ -669,6 +669,12 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'livePhotoVideoId'?: string | null;
/**
*
* @type {string}
* @memberof AssetResponseDto
*/
'localDateTime': string;
/**
*
* @type {string}
@ -6340,13 +6346,16 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
},
/**
*
* @param {string} timestamp Get pictures for +24 hours from this time going back x years
* @param {number} day
* @param {number} month
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMemoryLane: async (timestamp: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'timestamp' is not null or undefined
assertParamExists('getMemoryLane', 'timestamp', timestamp)
getMemoryLane: async (day: number, month: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'day' is not null or undefined
assertParamExists('getMemoryLane', 'day', day)
// verify required parameter 'month' is not null or undefined
assertParamExists('getMemoryLane', 'month', month)
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);
@ -6368,10 +6377,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (timestamp !== undefined) {
localVarQueryParameter['timestamp'] = (timestamp as any instanceof Date) ?
(timestamp as any).toISOString() :
timestamp;
if (day !== undefined) {
localVarQueryParameter['day'] = day;
}
if (month !== undefined) {
localVarQueryParameter['month'] = month;
}
@ -7152,12 +7163,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} timestamp Get pictures for +24 hours from this time going back x years
* @param {number} day
* @param {number} month
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMemoryLane(timestamp: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
async getMemoryLane(day: number, month: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(day, month, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -7443,7 +7455,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @throws {RequiredError}
*/
getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
return localVarFp.getMemoryLane(requestParameters.day, requestParameters.month, options).then((request) => request(axios, basePath));
},
/**
*
@ -7888,11 +7900,18 @@ export interface AssetApiGetMapMarkersRequest {
*/
export interface AssetApiGetMemoryLaneRequest {
/**
* Get pictures for +24 hours from this time going back x years
* @type {string}
*
* @type {number}
* @memberof AssetApiGetMemoryLane
*/
readonly timestamp: string
readonly day: number
/**
*
* @type {number}
* @memberof AssetApiGetMemoryLane
*/
readonly month: number
}
/**
@ -8398,7 +8417,7 @@ export class AssetApi extends BaseAPI {
* @memberof AssetApi
*/
public getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath));
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.day, requestParameters.month, options).then((request) => request(this.axios, this.basePath));
}
/**

View file

@ -22,9 +22,9 @@ class MemoryService {
Future<List<Memory>?> getMemoryLane() async {
try {
final now = DateTime.now();
final beginningOfDate = DateTime(now.year, now.month, now.day);
final data = await _apiService.assetApi.getMemoryLane(
beginningOfDate,
now.day,
now.month,
);
if (data == null) {

View file

@ -963,7 +963,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(timestamp)
> List<MemoryLaneResponseDto> getMemoryLane(day, month)
@ -986,10 +986,11 @@ import 'package:openapi/api.dart';
//defaultApiClient.getAuthentication<HttpBearerAuth>('bearer').setAccessToken(yourTokenGeneratorFunction);
final api_instance = AssetApi();
final timestamp = 2013-10-20T19:20:30+01:00; // DateTime | Get pictures for +24 hours from this time going back x years
final day = 56; // int |
final month = 56; // int |
try {
final result = api_instance.getMemoryLane(timestamp);
final result = api_instance.getMemoryLane(day, month);
print(result);
} catch (e) {
print('Exception when calling AssetApi->getMemoryLane: $e\n');
@ -1000,7 +1001,8 @@ try {
Name | Type | Description | Notes
------------- | ------------- | ------------- | -------------
**timestamp** | **DateTime**| Get pictures for +24 hours from this time going back x years |
**day** | **int**| |
**month** | **int**| |
### Return type

View file

@ -23,6 +23,7 @@ Name | Type | Description | Notes
**isReadOnly** | **bool** | |
**libraryId** | **String** | |
**livePhotoVideoId** | **String** | | [optional]
**localDateTime** | [**DateTime**](DateTime.md) | |
**originalFileName** | **String** | |
**originalPath** | **String** | |
**owner** | [**UserResponseDto**](UserResponseDto.md) | | [optional]

View file

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

View file

@ -28,6 +28,7 @@ class AssetResponseDto {
required this.isReadOnly,
required this.libraryId,
this.livePhotoVideoId,
required this.localDateTime,
required this.originalFileName,
required this.originalPath,
this.owner,
@ -78,6 +79,8 @@ class AssetResponseDto {
String? livePhotoVideoId;
DateTime localDateTime;
String originalFileName;
String originalPath;
@ -130,6 +133,7 @@ class AssetResponseDto {
other.isReadOnly == isReadOnly &&
other.libraryId == libraryId &&
other.livePhotoVideoId == livePhotoVideoId &&
other.localDateTime == localDateTime &&
other.originalFileName == originalFileName &&
other.originalPath == originalPath &&
other.owner == owner &&
@ -160,6 +164,7 @@ class AssetResponseDto {
(isReadOnly.hashCode) +
(libraryId.hashCode) +
(livePhotoVideoId == null ? 0 : livePhotoVideoId!.hashCode) +
(localDateTime.hashCode) +
(originalFileName.hashCode) +
(originalPath.hashCode) +
(owner == null ? 0 : owner!.hashCode) +
@ -173,7 +178,7 @@ class AssetResponseDto {
(updatedAt.hashCode);
@override
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
String toString() => 'AssetResponseDto[checksum=$checksum, deviceAssetId=$deviceAssetId, deviceId=$deviceId, duration=$duration, exifInfo=$exifInfo, fileCreatedAt=$fileCreatedAt, fileModifiedAt=$fileModifiedAt, id=$id, isArchived=$isArchived, isExternal=$isExternal, isFavorite=$isFavorite, isOffline=$isOffline, isReadOnly=$isReadOnly, libraryId=$libraryId, livePhotoVideoId=$livePhotoVideoId, localDateTime=$localDateTime, originalFileName=$originalFileName, originalPath=$originalPath, owner=$owner, ownerId=$ownerId, people=$people, resized=$resized, smartInfo=$smartInfo, tags=$tags, thumbhash=$thumbhash, type=$type, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -200,6 +205,7 @@ class AssetResponseDto {
} else {
// json[r'livePhotoVideoId'] = null;
}
json[r'localDateTime'] = this.localDateTime.toUtc().toIso8601String();
json[r'originalFileName'] = this.originalFileName;
json[r'originalPath'] = this.originalPath;
if (this.owner != null) {
@ -249,6 +255,7 @@ class AssetResponseDto {
isReadOnly: mapValueOfType<bool>(json, r'isReadOnly')!,
libraryId: mapValueOfType<String>(json, r'libraryId')!,
livePhotoVideoId: mapValueOfType<String>(json, r'livePhotoVideoId'),
localDateTime: mapDateTime(json, r'localDateTime', '')!,
originalFileName: mapValueOfType<String>(json, r'originalFileName')!,
originalPath: mapValueOfType<String>(json, r'originalPath')!,
owner: UserResponseDto.fromJson(json[r'owner']),
@ -320,6 +327,7 @@ class AssetResponseDto {
'isOffline',
'isReadOnly',
'libraryId',
'localDateTime',
'originalFileName',
'originalPath',
'ownerId',

View file

@ -107,7 +107,7 @@ void main() {
// TODO
});
//Future<List<MemoryLaneResponseDto>> getMemoryLane(DateTime timestamp) async
//Future<List<MemoryLaneResponseDto>> getMemoryLane(int day, int month) async
test('test getMemoryLane', () async {
// TODO
});

View file

@ -92,6 +92,11 @@ void main() {
// TODO
});
// DateTime localDateTime
test('to test the property `localDateTime`', () async {
// TODO
});
// String originalFileName
test('to test the property `originalFileName`', () async {
// TODO

View file

@ -1477,13 +1477,19 @@
"operationId": "getMemoryLane",
"parameters": [
{
"name": "timestamp",
"name": "day",
"required": true,
"in": "query",
"description": "Get pictures for +24 hours from this time going back x years",
"schema": {
"format": "date-time",
"type": "string"
"type": "integer"
}
},
{
"name": "month",
"required": true,
"in": "query",
"schema": {
"type": "integer"
}
}
],
@ -5617,6 +5623,10 @@
"nullable": true,
"type": "string"
},
"localDateTime": {
"format": "date-time",
"type": "string"
},
"originalFileName": {
"type": "string"
},
@ -5676,6 +5686,7 @@
"updatedAt",
"isFavorite",
"isArchived",
"localDateTime",
"isOffline",
"isExternal",
"isReadOnly",

View file

@ -68,12 +68,34 @@ export interface TimeBucketItem {
count: number;
}
export type AssetCreate = Pick<
AssetEntity,
| 'deviceAssetId'
| 'ownerId'
| 'libraryId'
| 'deviceId'
| 'type'
| 'originalPath'
| 'fileCreatedAt'
| 'localDateTime'
| 'fileModifiedAt'
| 'checksum'
| 'originalFileName'
> &
Partial<AssetEntity>;
export interface MonthDay {
day: number;
month: number;
}
export const IAssetRepository = 'IAssetRepository';
export interface IAssetRepository {
create(asset: Partial<AssetEntity>): Promise<AssetEntity>;
create(asset: AssetCreate): Promise<AssetEntity>;
getByDate(ownerId: string, date: Date): Promise<AssetEntity[]>;
getByIds(ids: string[]): Promise<AssetEntity[]>;
getByDayOfYear(ownerId: string, monthDay: MonthDay): Promise<AssetEntity[]>;
getByChecksum(userId: string, checksum: Buffer): Promise<AssetEntity | null>;
getByAlbumId(pagination: PaginationOptions, albumId: string): Paginated<AssetEntity>;
getByUserId(pagination: PaginationOptions, userId: string): Paginated<AssetEntity>;
@ -87,7 +109,7 @@ export interface IAssetRepository {
deleteAll(ownerId: string): Promise<void>;
getAll(pagination: PaginationOptions, options?: AssetSearchOptions): Paginated<AssetEntity>;
updateAll(ids: string[], options: Partial<AssetEntity>): Promise<void>;
save(asset: Partial<AssetEntity>): Promise<AssetEntity>;
save(asset: Pick<AssetEntity, 'id'> & Partial<AssetEntity>): Promise<AssetEntity>;
findLivePhotoMatch(options: LivePhotoSearchOptions): Promise<AssetEntity | null>;
getMapMarkers(ownerId: string, options?: MapMarkerSearchOptions): Promise<MapMarker[]>;
getStatistics(ownerId: string, options: AssetStatsOptions): Promise<AssetStats>;

View file

@ -274,60 +274,24 @@ 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')],
]);
beforeAll(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2024-01-15'));
});
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')],
]);
afterAll(() => {
jest.useRealTimers();
});
it('should set the title correctly', async () => {
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2022-06-15T00:00:00.000Z'))
.mockResolvedValue([assetStub.image]);
when(assetMock.getByDate)
.calledWith(authStub.admin.id, new Date('2021-06-15T00:00:00.000Z'))
.mockResolvedValue([assetStub.video]);
assetMock.getByDayOfYear.mockResolvedValue([assetStub.image, assetStub.imageFrom2015]);
await expect(sut.getMemoryLane(authStub.admin, { timestamp: new Date(2023, 5, 15), years: 2 })).resolves.toEqual([
await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([
{ title: '1 year since...', assets: [mapAsset(assetStub.image)] },
{ title: '2 years since...', assets: [mapAsset(assetStub.video)] },
{ title: '9 years since...', assets: [mapAsset(assetStub.imageFrom2015)] },
]);
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')],
]);
expect(assetMock.getByDayOfYear.mock.calls).toEqual([[authStub.admin.id, { day: 15, month: 1 }]]);
});
});

View file

@ -1,6 +1,6 @@
import { AssetEntity } from '@app/infra/entities';
import { BadRequestException, Inject, Logger } from '@nestjs/common';
import { DateTime } from 'luxon';
import _ from 'lodash';
import { extname } from 'path';
import sanitize from 'sanitize-filename';
import { AccessCore, IAccessRepository, Permission } from '../access';
@ -138,22 +138,22 @@ export class AssetService {
}
async getMemoryLane(authUser: AuthUserDto, dto: MemoryLaneDto): Promise<MemoryLaneResponseDto[]> {
const target = DateTime.fromJSDate(dto.timestamp);
const currentYear = new Date().getFullYear();
const assets = await this.assetRepository.getByDayOfYear(authUser.id, dto);
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)),
};
};
return _.chain(assets)
.filter((asset) => asset.localDateTime.getFullYear() < currentYear)
.map((asset) => {
const years = currentYear - asset.localDateTime.getFullYear();
const requests: Promise<MemoryLaneResponseDto>[] = [];
for (let i = 1; i <= dto.years; i++) {
requests.push(onRequest(i));
}
return Promise.all(requests).then((results) => results.filter((result) => result.assets.length > 0));
return {
title: `${years} year${years > 1 ? 's' : ''} since...`,
asset: mapAsset(asset),
};
})
.groupBy((asset) => asset.title)
.map((items, title) => ({ title, assets: items.map(({ asset }) => asset) }))
.value();
}
private async timeBucketChecks(authUser: AuthUserDto, dto: TimeBucketDto) {

View file

@ -1,14 +1,19 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsDate, IsNumber, IsPositive } from 'class-validator';
import { IsInt, Max, Min } 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()
@IsInt()
@Type(() => Number)
years = 30;
@Max(31)
@Min(1)
@ApiProperty({ type: 'integer' })
day!: number;
@IsInt()
@Type(() => Number)
@Max(12)
@Min(1)
@ApiProperty({ type: 'integer' })
month!: number;
}

View file

@ -26,6 +26,7 @@ export class AssetResponseDto {
updatedAt!: Date;
isFavorite!: boolean;
isArchived!: boolean;
localDateTime!: Date;
isOffline!: boolean;
isExternal!: boolean;
isReadOnly!: boolean;
@ -54,6 +55,7 @@ function _map(entity: AssetEntity, withExif: boolean): AssetResponseDto {
thumbhash: entity.thumbhash?.toString('base64') ?? null,
fileCreatedAt: entity.fileCreatedAt,
fileModifiedAt: entity.fileModifiedAt,
localDateTime: entity.localDateTime,
updatedAt: entity.updatedAt,
isFavorite: entity.isFavorite,
isArchived: entity.isArchived,

View file

@ -217,6 +217,7 @@ describe(LibraryService.name, () => {
deviceId: 'Library Import',
fileCreatedAt: expect.any(Date),
fileModifiedAt: expect.any(Date),
localDateTime: expect.any(Date),
type: AssetType.IMAGE,
originalFileName: 'photo',
sidecarPath: null,
@ -264,6 +265,7 @@ describe(LibraryService.name, () => {
deviceId: 'Library Import',
fileCreatedAt: expect.any(Date),
fileModifiedAt: expect.any(Date),
localDateTime: expect.any(Date),
type: AssetType.IMAGE,
originalFileName: 'photo',
sidecarPath: '/data/user1/photo.jpg.xmp',
@ -310,6 +312,7 @@ describe(LibraryService.name, () => {
deviceId: 'Library Import',
fileCreatedAt: expect.any(Date),
fileModifiedAt: expect.any(Date),
localDateTime: expect.any(Date),
type: AssetType.VIDEO,
originalFileName: 'video',
sidecarPath: null,

View file

@ -251,6 +251,7 @@ export class LibraryService {
deviceId: 'Library Import',
fileCreatedAt: stats.mtime,
fileModifiedAt: stats.mtime,
localDateTime: stats.mtime,
type: assetType,
originalFileName: parse(assetPath).name,
sidecarPath,

View file

@ -231,6 +231,7 @@ describe(MetadataService.name, () => {
id: assetStub.image.id,
duration: null,
fileCreatedAt: assetStub.image.createdAt,
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
});
});
@ -252,6 +253,7 @@ describe(MetadataService.name, () => {
id: assetStub.withLocation.id,
duration: null,
fileCreatedAt: assetStub.withLocation.createdAt,
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
});
});
@ -299,16 +301,13 @@ describe(MetadataService.name, () => {
const video = randomBytes(512);
storageMock.readFile.mockResolvedValue(video);
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetMock.save.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
expect(assetMock.save).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(assetMock.save).toHaveBeenCalledWith(
expect(assetMock.create).toHaveBeenCalledWith(
expect.objectContaining({
type: AssetType.VIDEO,
originalFileName: assetStub.livePhotoStillAsset.originalFileName,
@ -316,6 +315,10 @@ describe(MetadataService.name, () => {
isReadOnly: true,
}),
);
expect(assetMock.save).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
});
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.METADATA_EXTRACTION,
@ -379,6 +382,7 @@ describe(MetadataService.name, () => {
id: assetStub.image.id,
duration: null,
fileCreatedAt: new Date('1970-01-01'),
localDateTime: new Date('1970-01-01'),
});
});
});

View file

@ -30,6 +30,7 @@ type ExifEntityWithoutGeocodeAndTypeOrm = Omit<
>;
const exifDate = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.toDate() : null);
const tzOffset = (dt: ExifDateTime | string | undefined) => (dt instanceof ExifDateTime ? dt?.tzoffsetMinutes : null);
const validate = <T>(value: T): NonNullable<T> | null => {
// handle lists of numbers
@ -156,9 +157,18 @@ export class MetadataService {
await this.applyMotionPhotos(asset, tags);
await this.applyReverseGeocoding(asset, exifData);
await this.assetRepository.upsertExif(exifData);
let localDateTime = exifData.dateTimeOriginal ?? undefined;
const dateTimeOriginal = exifDate(firstDateTime(tags as Tags)) ?? exifData.dateTimeOriginal;
const timeZoneOffset = tzOffset(firstDateTime(tags as Tags)) ?? 0;
if (dateTimeOriginal && timeZoneOffset) {
localDateTime = new Date(dateTimeOriginal.getTime() + timeZoneOffset * 60000);
}
await this.assetRepository.save({
id: asset.id,
duration: tags.Duration ? this.getDuration(tags.Duration) : null,
localDateTime,
fileCreatedAt: exifData.dateTimeOriginal ?? undefined,
});
@ -268,11 +278,13 @@ export class MetadataService {
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
if (!motionAsset) {
motionAsset = await this.assetRepository.save({
const createdAt = asset.fileCreatedAt ?? asset.createdAt;
motionAsset = await this.assetRepository.create({
libraryId: asset.libraryId,
type: AssetType.VIDEO,
fileCreatedAt: asset.fileCreatedAt ?? asset.createdAt,
fileCreatedAt: createdAt,
fileModifiedAt: asset.fileModifiedAt,
localDateTime: createdAt,
checksum,
ownerId: asset.ownerId,
originalPath: this.storageCore.ensurePath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`),

View file

@ -1,3 +1,4 @@
import { AssetCreate } from '@app/domain';
import { AssetEntity } from '@app/infra/entities';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
@ -19,11 +20,6 @@ export interface AssetOwnerCheck extends AssetCheck {
ownerId: string;
}
export type AssetCreate = Omit<
AssetEntity,
'id' | 'createdAt' | 'updatedAt' | 'owner' | 'livePhotoVideoId' | 'library'
>;
export interface IAssetRepository {
get(id: string): Promise<AssetEntity | null>;
create(asset: AssetCreate): Promise<AssetEntity>;

View file

@ -29,6 +29,7 @@ export class AssetCore {
fileCreatedAt: dto.fileCreatedAt,
fileModifiedAt: dto.fileModifiedAt,
localDateTime: dto.fileCreatedAt,
type: mimeTypes.assetType(file.originalPath),
isFavorite: dto.isFavorite,

View file

@ -28,6 +28,8 @@ export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_library_checksum';
@Index(ASSET_CHECKSUM_CONSTRAINT, ['owner', 'library', 'checksum'], {
unique: true,
})
@Index('IDX_day_of_month', { synchronize: false })
@Index('IDX_month', { synchronize: false })
// For all assets, each originalpath must be unique per user and library
export class AssetEntity {
@PrimaryGeneratedColumn('uuid')
@ -78,6 +80,9 @@ export class AssetEntity {
@Column({ type: 'timestamptz' })
fileCreatedAt!: Date;
@Column({ type: 'timestamp' })
localDateTime!: Date;
@Column({ type: 'timestamptz' })
fileModifiedAt!: Date;

View file

@ -0,0 +1,30 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddLocalDateTime1694525143117 implements MigrationInterface {
name = 'AddLocalDateTime1694525143117';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "localDateTime" TIMESTAMP`);
await queryRunner.query(`
update "assets"
set "localDateTime" = "fileCreatedAt"`);
await queryRunner.query(`
update "assets"
set "localDateTime" = "fileCreatedAt" at TIME ZONE "exif"."timeZone"
from "exif"
where
"exif"."assetId" = "assets"."id" and
"exif"."timeZone" is not null`);
await queryRunner.query(`ALTER TABLE "assets" ALTER COLUMN "localDateTime" SET NOT NULL`);
await queryRunner.query(`CREATE INDEX "IDX_day_of_month" ON assets (EXTRACT(DAY FROM "localDateTime"))`);
await queryRunner.query(`CREATE INDEX "IDX_month" ON assets (EXTRACT(MONTH FROM "localDateTime"))`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "localDateTime"`);
await queryRunner.query(`DROP INDEX "IDX_day_of_month"`);
await queryRunner.query(`DROP INDEX "IDX_month"`);
}
}

View file

@ -1,4 +1,5 @@
import {
AssetCreate,
AssetSearchOptions,
AssetStats,
AssetStatsOptions,
@ -6,6 +7,7 @@ import {
LivePhotoSearchOptions,
MapMarker,
MapMarkerSearchOptions,
MonthDay,
Paginated,
PaginationOptions,
TimeBucketItem,
@ -38,9 +40,7 @@ export class AssetRepository implements IAssetRepository {
await this.exifRepository.upsert(exif, { conflictPaths: ['assetId'] });
}
create(
asset: Omit<AssetEntity, 'id' | 'createdAt' | 'updatedAt' | 'ownerId' | 'livePhotoVideoId'>,
): Promise<AssetEntity> {
create(asset: AssetCreate): Promise<AssetEntity> {
return this.repository.save(asset);
}
@ -78,6 +78,26 @@ export class AssetRepository implements IAssetRepository {
});
}
getByDayOfYear(ownerId: string, { day, month }: MonthDay): Promise<AssetEntity[]> {
return this.repository
.createQueryBuilder('entity')
.where(
`entity.ownerId = :ownerId
AND entity.isVisible = true
AND entity.isArchived = false
AND entity.resizePath IS NOT NULL
AND EXTRACT(DAY FROM entity.localDateTime) = :day
AND EXTRACT(MONTH FROM entity.localDateTime) = :month`,
{
ownerId,
day,
month,
},
)
.orderBy('entity.localDateTime', 'DESC')
.getMany();
}
getByIds(ids: string[]): Promise<AssetEntity[]> {
return this.repository.find({
where: { id: In(ids) },
@ -454,8 +474,9 @@ export class AssetRepository implements IAssetRepository {
getByTimeBucket(timeBucket: string, options: TimeBucketOptions): Promise<AssetEntity[]> {
const truncateValue = truncateMap[options.size];
return this.getBuilder(options)
.andWhere(`date_trunc('${truncateValue}', "fileCreatedAt") = :timeBucket`, { timeBucket })
.orderBy('asset.fileCreatedAt', 'DESC')
.andWhere(`date_trunc('${truncateValue}', "localDateTime") = :timeBucket`, { timeBucket })
.orderBy(`date_trunc('day', "localDateTime")`, 'DESC')
.addOrderBy('asset.fileCreatedAt', 'DESC')
.getMany();
}

View file

@ -56,7 +56,7 @@ const createAsset = (
createdAt: Date,
): Promise<AssetEntity> => {
const id = assetCount++;
return repository.save({
return repository.create({
ownerId: loginResponse.userId,
checksum: randomBytes(20),
originalPath: `/tests/test_${id}`,
@ -66,6 +66,7 @@ const createAsset = (
isVisible: true,
fileCreatedAt: createdAt,
fileModifiedAt: new Date(),
localDateTime: createdAt,
type: AssetType.IMAGE,
originalFileName: `test_${id}`,
});

View file

@ -23,6 +23,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
@ -56,6 +57,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
duration: null,
@ -93,6 +95,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
@ -127,6 +130,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
@ -164,6 +168,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
@ -201,6 +206,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
@ -238,6 +244,45 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
library: libraryStub.uploadLibrary1,
isExternal: false,
isOffline: false,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5_000,
} as ExifEntity,
}),
imageFrom2015: Object.freeze<AssetEntity>({
id: 'asset-id-1',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2015-02-23T05:06:29.716Z'),
updatedAt: new Date('2015-02-23T05:06:29.716Z'),
localDateTime: new Date('2015-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isExternal: false,
@ -276,6 +321,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,
@ -344,6 +390,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: false,
isArchived: false,
isReadOnly: false,
@ -382,6 +429,7 @@ export const assetStub = {
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isArchived: false,
isReadOnly: false,

View file

@ -55,6 +55,7 @@ const assetResponse: AssetResponseDto = {
isReadOnly: false,
isOffline: false,
fileCreatedAt: today,
localDateTime: today,
updatedAt: today,
isFavorite: false,
isArchived: false,
@ -174,6 +175,7 @@ export const sharedLinkStub = {
checksum: Buffer.from('file hash', 'utf8'),
fileModifiedAt: today,
fileCreatedAt: today,
localDateTime: today,
createdAt: today,
updatedAt: today,
isFavorite: false,

View file

@ -5,6 +5,7 @@ export const newAssetRepositoryMock = (): jest.Mocked<IAssetRepository> => {
create: jest.fn(),
upsertExif: jest.fn(),
getByDate: jest.fn(),
getByDayOfYear: jest.fn(),
getByIds: jest.fn().mockResolvedValue([]),
getByAlbumId: jest.fn(),
getByUserId: jest.fn(),

View file

@ -669,6 +669,12 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto
*/
'livePhotoVideoId'?: string | null;
/**
*
* @type {string}
* @memberof AssetResponseDto
*/
'localDateTime': string;
/**
*
* @type {string}
@ -6340,13 +6346,16 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
},
/**
*
* @param {string} timestamp Get pictures for +24 hours from this time going back x years
* @param {number} day
* @param {number} month
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getMemoryLane: async (timestamp: string, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'timestamp' is not null or undefined
assertParamExists('getMemoryLane', 'timestamp', timestamp)
getMemoryLane: async (day: number, month: number, options: AxiosRequestConfig = {}): Promise<RequestArgs> => {
// verify required parameter 'day' is not null or undefined
assertParamExists('getMemoryLane', 'day', day)
// verify required parameter 'month' is not null or undefined
assertParamExists('getMemoryLane', 'month', month)
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);
@ -6368,10 +6377,12 @@ export const AssetApiAxiosParamCreator = function (configuration?: Configuration
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration)
if (timestamp !== undefined) {
localVarQueryParameter['timestamp'] = (timestamp as any instanceof Date) ?
(timestamp as any).toISOString() :
timestamp;
if (day !== undefined) {
localVarQueryParameter['day'] = day;
}
if (month !== undefined) {
localVarQueryParameter['month'] = month;
}
@ -7152,12 +7163,13 @@ export const AssetApiFp = function(configuration?: Configuration) {
},
/**
*
* @param {string} timestamp Get pictures for +24 hours from this time going back x years
* @param {number} day
* @param {number} month
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getMemoryLane(timestamp: string, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(timestamp, options);
async getMemoryLane(day: number, month: number, options?: AxiosRequestConfig): Promise<(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Array<MemoryLaneResponseDto>>> {
const localVarAxiosArgs = await localVarAxiosParamCreator.getMemoryLane(day, month, options);
return createRequestFunction(localVarAxiosArgs, globalAxios, BASE_PATH, configuration);
},
/**
@ -7443,7 +7455,7 @@ export const AssetApiFactory = function (configuration?: Configuration, basePath
* @throws {RequiredError}
*/
getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig): AxiosPromise<Array<MemoryLaneResponseDto>> {
return localVarFp.getMemoryLane(requestParameters.timestamp, options).then((request) => request(axios, basePath));
return localVarFp.getMemoryLane(requestParameters.day, requestParameters.month, options).then((request) => request(axios, basePath));
},
/**
*
@ -7888,11 +7900,18 @@ export interface AssetApiGetMapMarkersRequest {
*/
export interface AssetApiGetMemoryLaneRequest {
/**
* Get pictures for +24 hours from this time going back x years
* @type {string}
*
* @type {number}
* @memberof AssetApiGetMemoryLane
*/
readonly timestamp: string
readonly day: number
/**
*
* @type {number}
* @memberof AssetApiGetMemoryLane
*/
readonly month: number
}
/**
@ -8398,7 +8417,7 @@ export class AssetApi extends BaseAPI {
* @memberof AssetApi
*/
public getMemoryLane(requestParameters: AssetApiGetMemoryLaneRequest, options?: AxiosRequestConfig) {
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.timestamp, options).then((request) => request(this.axios, this.basePath));
return AssetApiFp(this.configuration).getMemoryLane(requestParameters.day, requestParameters.month, options).then((request) => request(this.axios, this.basePath));
}
/**

View file

@ -87,8 +87,10 @@
onMount(async () => {
if (!$memoryStore) {
const localTime = new Date();
const { data } = await api.assetApi.getMemoryLane({
timestamp: DateTime.local().startOf('day').toISO() || '',
month: localTime.getMonth() + 1,
day: localTime.getDate(),
});
$memoryStore = data;
}
@ -212,7 +214,7 @@
<div class="absolute left-8 top-4 text-sm font-medium text-white">
<p>
{DateTime.fromISO(currentMemory.assets[0].fileCreatedAt).toLocaleString(DateTime.DATE_FULL)}
{DateTime.fromISO(currentMemory.assets[0].localDateTime).toLocaleString(DateTime.DATE_FULL)}
</p>
<p>
{currentAsset.exifInfo?.city || ''}

View file

@ -126,7 +126,8 @@
<section id="asset-group-by-date" class="flex flex-wrap gap-x-12" bind:clientHeight={actualBucketHeight}>
{#each assetsGroupByDate as groupAssets, groupIndex (groupAssets[0].id)}
{@const groupTitle = formatGroupTitle(DateTime.fromISO(groupAssets[0].fileCreatedAt).startOf('day'))}
{@const asset = groupAssets[0]}
{@const groupTitle = formatGroupTitle(DateTime.fromISO(asset.localDateTime).startOf('day'))}
<!-- Asset Group By Date -->
<!-- svelte-ignore a11y-no-static-element-interactions -->

View file

@ -1,6 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { DateTime } from 'luxon';
import { api } from '@api';
import ChevronLeft from 'svelte-material-icons/ChevronLeft.svelte';
import ChevronRight from 'svelte-material-icons/ChevronRight.svelte';
@ -11,8 +10,10 @@
$: shouldRender = $memoryStore?.length > 0;
onMount(async () => {
const localTime = new Date();
const { data } = await api.assetApi.getMemoryLane({
timestamp: DateTime.local().startOf('day').toISO() || '',
month: localTime.getMonth() + 1,
day: localTime.getDate(),
});
$memoryStore = data;
});

View file

@ -45,7 +45,7 @@ export function splitBucketIntoDateGroups(
): AssetResponseDto[][] {
return lodash
.chain(assets)
.groupBy((a) => new Date(a.fileCreatedAt).toLocaleDateString(locale, groupDateFormat))
.groupBy((asset) => new Date(asset.localDateTime).toLocaleDateString(locale, groupDateFormat))
.sortBy((group) => assets.indexOf(group[0]))
.value();
}