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
32 changed files with 337 additions and 147 deletions

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`),