mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00: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:
@@ -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",
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 }]]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -29,6 +29,7 @@ export class AssetCore {
|
||||
|
||||
fileCreatedAt: dto.fileCreatedAt,
|
||||
fileModifiedAt: dto.fileModifiedAt,
|
||||
localDateTime: dto.fileCreatedAt,
|
||||
|
||||
type: mimeTypes.assetType(file.originalPath),
|
||||
isFavorite: dto.isFavorite,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`,
|
||||
});
|
||||
|
||||
48
server/test/fixtures/asset.stub.ts
vendored
48
server/test/fixtures/asset.stub.ts
vendored
@@ -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,
|
||||
|
||||
2
server/test/fixtures/shared-link.stub.ts
vendored
2
server/test/fixtures/shared-link.stub.ts
vendored
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user