mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
feat: persistent memories (#15953)
feat: memories refactor chore: use heart as favorite icon fix: linting
This commit is contained in:
parent
502f6e020d
commit
d350022dec
29 changed files with 585 additions and 70 deletions
|
|
@ -1,8 +1,8 @@
|
|||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto } from 'src/dtos/memory.dto';
|
||||
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto } from 'src/dtos/memory.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { Auth, Authenticated } from 'src/middleware/auth.guard';
|
||||
import { MemoryService } from 'src/services/memory.service';
|
||||
|
|
@ -15,8 +15,8 @@ export class MemoryController {
|
|||
|
||||
@Get()
|
||||
@Authenticated({ permission: Permission.MEMORY_READ })
|
||||
searchMemories(@Auth() auth: AuthDto): Promise<MemoryResponseDto[]> {
|
||||
return this.service.search(auth);
|
||||
searchMemories(@Auth() auth: AuthDto, @Query() dto: MemorySearchDto): Promise<MemoryResponseDto[]> {
|
||||
return this.service.search(auth, dto);
|
||||
}
|
||||
|
||||
@Post()
|
||||
|
|
|
|||
2
server/src/db.d.ts
vendored
2
server/src/db.d.ts
vendored
|
|
@ -227,11 +227,13 @@ export interface Memories {
|
|||
createdAt: Generated<Timestamp>;
|
||||
data: Json;
|
||||
deletedAt: Timestamp | null;
|
||||
hideAt: Timestamp | null;
|
||||
id: Generated<string>;
|
||||
isSaved: Generated<boolean>;
|
||||
memoryAt: Timestamp;
|
||||
ownerId: string;
|
||||
seenAt: Timestamp | null;
|
||||
showAt: Timestamp | null;
|
||||
type: string;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
|
|||
import { AssetEntity } from 'src/entities/asset.entity';
|
||||
import { MemoryType } from 'src/enum';
|
||||
import { MemoryItem } from 'src/types';
|
||||
import { ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
import { Optional, ValidateBoolean, ValidateDate, ValidateUUID } from 'src/validation';
|
||||
|
||||
class MemoryBaseDto {
|
||||
@ValidateBoolean({ optional: true })
|
||||
|
|
@ -15,6 +15,22 @@ class MemoryBaseDto {
|
|||
seenAt?: Date;
|
||||
}
|
||||
|
||||
export class MemorySearchDto {
|
||||
@Optional()
|
||||
@IsEnum(MemoryType)
|
||||
@ApiProperty({ enum: MemoryType, enumName: 'MemoryType' })
|
||||
type?: MemoryType;
|
||||
|
||||
@ValidateDate({ optional: true })
|
||||
for?: Date;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isTrashed?: boolean;
|
||||
|
||||
@ValidateBoolean({ optional: true })
|
||||
isSaved?: boolean;
|
||||
}
|
||||
|
||||
class OnThisDayDto {
|
||||
@IsInt()
|
||||
@IsPositive()
|
||||
|
|
@ -62,6 +78,8 @@ export class MemoryResponseDto {
|
|||
deletedAt?: Date;
|
||||
memoryAt!: Date;
|
||||
seenAt?: Date;
|
||||
showAt?: Date;
|
||||
hideAt?: Date;
|
||||
ownerId!: string;
|
||||
@ApiProperty({ enumName: 'MemoryType', enum: MemoryType })
|
||||
type!: MemoryType;
|
||||
|
|
@ -78,6 +96,8 @@ export const mapMemory = (entity: MemoryItem): MemoryResponseDto => {
|
|||
deletedAt: entity.deletedAt ?? undefined,
|
||||
memoryAt: entity.memoryAt,
|
||||
seenAt: entity.seenAt ?? undefined,
|
||||
showAt: entity.showAt ?? undefined,
|
||||
hideAt: entity.hideAt ?? undefined,
|
||||
ownerId: entity.ownerId,
|
||||
type: entity.type as MemoryType,
|
||||
data: entity.data as unknown as MemoryData,
|
||||
|
|
|
|||
|
|
@ -53,6 +53,12 @@ export class MemoryEntity<T extends MemoryType = MemoryType> {
|
|||
@Column({ type: 'timestamptz' })
|
||||
memoryAt!: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
showAt?: Date;
|
||||
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
hideAt?: Date;
|
||||
|
||||
/** when the user last viewed the memory */
|
||||
@Column({ type: 'timestamptz', nullable: true })
|
||||
seenAt?: Date;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@ export class SystemMetadataEntity<T extends keyof SystemMetadata = SystemMetadat
|
|||
|
||||
export type VersionCheckMetadata = { checkedAt: string; releaseVersion: string };
|
||||
export type SystemFlags = { mountChecks: Record<StorageFolder, boolean> };
|
||||
export type MemoriesState = {
|
||||
/** memories have already been created through this date */
|
||||
lastOnThisDayDate: string;
|
||||
};
|
||||
|
||||
export interface SystemMetadata extends Record<SystemMetadataKey, Record<string, any>> {
|
||||
[SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean };
|
||||
|
|
@ -23,4 +27,5 @@ export interface SystemMetadata extends Record<SystemMetadataKey, Record<string,
|
|||
[SystemMetadataKey.SYSTEM_CONFIG]: DeepPartial<SystemConfig>;
|
||||
[SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial<SystemFlags>;
|
||||
[SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata;
|
||||
[SystemMetadataKey.MEMORIES_STATE]: MemoriesState;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ export enum StorageFolder {
|
|||
export enum SystemMetadataKey {
|
||||
REVERSE_GEOCODING_STATE = 'reverse-geocoding-state',
|
||||
FACIAL_RECOGNITION_STATE = 'facial-recognition-state',
|
||||
MEMORIES_STATE = 'memories-state',
|
||||
ADMIN_ONBOARDING = 'admin-onboarding',
|
||||
SYSTEM_CONFIG = 'system-config',
|
||||
SYSTEM_FLAGS = 'system-flags',
|
||||
|
|
@ -233,6 +234,8 @@ export enum ManualJobName {
|
|||
PERSON_CLEANUP = 'person-cleanup',
|
||||
TAG_CLEANUP = 'tag-cleanup',
|
||||
USER_CLEANUP = 'user-cleanup',
|
||||
MEMORY_CLEANUP = 'memory-cleanup',
|
||||
MEMORY_CREATE = 'memory-create',
|
||||
}
|
||||
|
||||
export enum AssetPathType {
|
||||
|
|
@ -477,6 +480,10 @@ export enum JobName {
|
|||
CLEAN_OLD_AUDIT_LOGS = 'clean-old-audit-logs',
|
||||
CLEAN_OLD_SESSION_TOKENS = 'clean-old-session-tokens',
|
||||
|
||||
// memories
|
||||
MEMORIES_CLEANUP = 'memories-cleanup',
|
||||
MEMORIES_CREATE = 'memories-create',
|
||||
|
||||
// smart search
|
||||
QUEUE_SMART_SEARCH = 'queue-smart-search',
|
||||
SMART_SEARCH = 'smart-search',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { MigrationInterface, QueryRunner } from "typeorm";
|
||||
|
||||
export class AddMemoryShowHideDates1739824470990 implements MigrationInterface {
|
||||
name = 'AddMemoryShowHideDates1739824470990'
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "memories" ADD "showAt" TIMESTAMP WITH TIME ZONE`);
|
||||
await queryRunner.query(`ALTER TABLE "memories" ADD "hideAt" TIMESTAMP WITH TIME ZONE`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "hideAt"`);
|
||||
await queryRunner.query(`ALTER TABLE "memories" DROP COLUMN "showAt"`);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -1,12 +1,68 @@
|
|||
-- NOTE: This file is auto generated by ./sql-generator
|
||||
|
||||
-- MemoryRepository.cleanup
|
||||
delete from "memories"
|
||||
where
|
||||
"createdAt" < $1
|
||||
and "isSaved" = $2
|
||||
|
||||
-- MemoryRepository.search
|
||||
select
|
||||
*
|
||||
"memories".*,
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"assets".*
|
||||
from
|
||||
"assets"
|
||||
inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId"
|
||||
where
|
||||
"memories_assets_assets"."memoriesId" = "memories"."id"
|
||||
and "assets"."deletedAt" is null
|
||||
) as agg
|
||||
) as "assets"
|
||||
from
|
||||
"memories"
|
||||
where
|
||||
"ownerId" = $1
|
||||
"deletedAt" is null
|
||||
and "ownerId" = $1
|
||||
order by
|
||||
"memoryAt" desc
|
||||
|
||||
-- MemoryRepository.search (date filter)
|
||||
select
|
||||
"memories".*,
|
||||
(
|
||||
select
|
||||
coalesce(json_agg(agg), '[]')
|
||||
from
|
||||
(
|
||||
select
|
||||
"assets".*
|
||||
from
|
||||
"assets"
|
||||
inner join "memories_assets_assets" on "assets"."id" = "memories_assets_assets"."assetsId"
|
||||
where
|
||||
"memories_assets_assets"."memoriesId" = "memories"."id"
|
||||
and "assets"."deletedAt" is null
|
||||
) as agg
|
||||
) as "assets"
|
||||
from
|
||||
"memories"
|
||||
where
|
||||
(
|
||||
"showAt" is null
|
||||
or "showAt" <= $1
|
||||
)
|
||||
and (
|
||||
"hideAt" is null
|
||||
or "hideAt" >= $2
|
||||
)
|
||||
and "deletedAt" is null
|
||||
and "ownerId" = $3
|
||||
order by
|
||||
"memoryAt" desc
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { Insertable, Kysely, Updateable } from 'kysely';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { DateTime } from 'luxon';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { DB, Memories } from 'src/db';
|
||||
import { Chunked, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
|
||||
import { MemorySearchDto } from 'src/dtos/memory.dto';
|
||||
import { IBulkAsset } from 'src/types';
|
||||
|
||||
@Injectable()
|
||||
|
|
@ -11,10 +13,40 @@ export class MemoryRepository implements IBulkAsset {
|
|||
constructor(@InjectKysely() private db: Kysely<DB>) {}
|
||||
|
||||
@GenerateSql({ params: [DummyValue.UUID] })
|
||||
search(ownerId: string) {
|
||||
cleanup() {
|
||||
return this.db
|
||||
.deleteFrom('memories')
|
||||
.where('createdAt', '<', DateTime.now().minus({ days: 30 }).toJSDate())
|
||||
.where('isSaved', '=', false)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@GenerateSql(
|
||||
{ params: [DummyValue.UUID, {}] },
|
||||
{ name: 'date filter', params: [DummyValue.UUID, { for: DummyValue.DATE }] },
|
||||
)
|
||||
search(ownerId: string, dto: MemorySearchDto) {
|
||||
return this.db
|
||||
.selectFrom('memories')
|
||||
.selectAll()
|
||||
.selectAll('memories')
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('assets')
|
||||
.selectAll('assets')
|
||||
.innerJoin('memories_assets_assets', 'assets.id', 'memories_assets_assets.assetsId')
|
||||
.whereRef('memories_assets_assets.memoriesId', '=', 'memories.id')
|
||||
.where('assets.deletedAt', 'is', null),
|
||||
).as('assets'),
|
||||
)
|
||||
.$if(dto.isSaved !== undefined, (qb) => qb.where('isSaved', '=', dto.isSaved!))
|
||||
.$if(dto.type !== undefined, (qb) => qb.where('type', '=', dto.type!))
|
||||
.$if(dto.for !== undefined, (qb) =>
|
||||
qb
|
||||
.where((where) => where.or([where('showAt', 'is', null), where('showAt', '<=', dto.for!)]))
|
||||
.where((where) => where.or([where('hideAt', 'is', null), where('hideAt', '>=', dto.for!)])),
|
||||
)
|
||||
.where('deletedAt', dto.isTrashed ? 'is not' : 'is', null)
|
||||
.where('ownerId', '=', ownerId)
|
||||
.orderBy('memoryAt', 'desc')
|
||||
.execute();
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ describe(JobService.name, () => {
|
|||
{ name: JobName.ASSET_DELETION_CHECK },
|
||||
{ name: JobName.USER_DELETE_CHECK },
|
||||
{ name: JobName.PERSON_CLEANUP },
|
||||
{ name: JobName.MEMORIES_CLEANUP },
|
||||
{ name: JobName.MEMORIES_CREATE },
|
||||
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
|
||||
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
|
||||
{ name: JobName.USER_SYNC_USAGE },
|
||||
|
|
|
|||
|
|
@ -31,6 +31,14 @@ const asJobItem = (dto: JobCreateDto): JobItem => {
|
|||
return { name: JobName.USER_DELETE_CHECK };
|
||||
}
|
||||
|
||||
case ManualJobName.MEMORY_CLEANUP: {
|
||||
return { name: JobName.MEMORIES_CLEANUP };
|
||||
}
|
||||
|
||||
case ManualJobName.MEMORY_CREATE: {
|
||||
return { name: JobName.MEMORIES_CREATE };
|
||||
}
|
||||
|
||||
default: {
|
||||
throw new BadRequestException('Invalid job name');
|
||||
}
|
||||
|
|
@ -207,6 +215,8 @@ export class JobService extends BaseService {
|
|||
{ name: JobName.ASSET_DELETION_CHECK },
|
||||
{ name: JobName.USER_DELETE_CHECK },
|
||||
{ name: JobName.PERSON_CLEANUP },
|
||||
{ name: JobName.MEMORIES_CLEANUP },
|
||||
{ name: JobName.MEMORIES_CREATE },
|
||||
{ name: JobName.QUEUE_GENERATE_THUMBNAILS, data: { force: false } },
|
||||
{ name: JobName.CLEAN_OLD_AUDIT_LOGS },
|
||||
{ name: JobName.USER_SYNC_USAGE },
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ describe(MemoryService.name, () => {
|
|||
describe('search', () => {
|
||||
it('should search memories', async () => {
|
||||
mocks.memory.search.mockResolvedValue([memoryStub.memory1, memoryStub.empty]);
|
||||
await expect(sut.search(authStub.admin)).resolves.toEqual(
|
||||
await expect(sut.search(authStub.admin, {})).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: 'memory1', assets: expect.any(Array) }),
|
||||
expect.objectContaining({ id: 'memoryEmpty', assets: [] }),
|
||||
|
|
@ -30,7 +30,7 @@ describe(MemoryService.name, () => {
|
|||
});
|
||||
|
||||
it('should map ', async () => {
|
||||
await expect(sut.search(authStub.admin)).resolves.toEqual([]);
|
||||
await expect(sut.search(authStub.admin, {})).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,84 @@
|
|||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { DateTime } from 'luxon';
|
||||
import { JsonObject } from 'src/db';
|
||||
import { OnJob } from 'src/decorators';
|
||||
import { BulkIdResponseDto, BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { MemoryCreateDto, MemoryResponseDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
|
||||
import { Permission } from 'src/enum';
|
||||
import { MemoryCreateDto, MemoryResponseDto, MemorySearchDto, MemoryUpdateDto, mapMemory } from 'src/dtos/memory.dto';
|
||||
import { OnThisDayData } from 'src/entities/memory.entity';
|
||||
import { JobName, MemoryType, Permission, QueueName, SystemMetadataKey } from 'src/enum';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
import { addAssets, removeAssets } from 'src/utils/asset.util';
|
||||
import { addAssets, getMyPartnerIds, removeAssets } from 'src/utils/asset.util';
|
||||
|
||||
const DAYS = 3;
|
||||
|
||||
@Injectable()
|
||||
export class MemoryService extends BaseService {
|
||||
async search(auth: AuthDto) {
|
||||
const memories = await this.memoryRepository.search(auth.user.id);
|
||||
@OnJob({ name: JobName.MEMORIES_CREATE, queue: QueueName.BACKGROUND_TASK })
|
||||
async onMemoriesCreate() {
|
||||
const users = await this.userRepository.getList({ withDeleted: false });
|
||||
const userMap: Record<string, string[]> = {};
|
||||
for (const user of users) {
|
||||
const partnerIds = await getMyPartnerIds({
|
||||
userId: user.id,
|
||||
repository: this.partnerRepository,
|
||||
timelineEnabled: true,
|
||||
});
|
||||
userMap[user.id] = [user.id, ...partnerIds];
|
||||
}
|
||||
|
||||
const start = DateTime.utc().startOf('day').minus({ days: DAYS });
|
||||
|
||||
const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE);
|
||||
let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start;
|
||||
|
||||
// generate a memory +/- X days from today
|
||||
for (let i = 0; i <= DAYS * 2 + 1; i++) {
|
||||
const target = start.plus({ days: i });
|
||||
if (lastOnThisDayDate > target) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const showAt = target.startOf('day').toISO();
|
||||
const hideAt = target.endOf('day').toISO();
|
||||
|
||||
this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`);
|
||||
|
||||
for (const [userId, userIds] of Object.entries(userMap)) {
|
||||
const memories = await this.assetRepository.getByDayOfYear(userIds, target);
|
||||
|
||||
for (const memory of memories) {
|
||||
const data: OnThisDayData = { year: target.year - memory.yearsAgo };
|
||||
await this.memoryRepository.create(
|
||||
{
|
||||
ownerId: userId,
|
||||
type: MemoryType.ON_THIS_DAY,
|
||||
data,
|
||||
memoryAt: target.minus({ years: memory.yearsAgo }).toISO(),
|
||||
showAt,
|
||||
hideAt,
|
||||
},
|
||||
new Set(memory.assets.map(({ id }) => id)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
await this.systemMetadataRepository.set(SystemMetadataKey.MEMORIES_STATE, {
|
||||
...state,
|
||||
lastOnThisDayDate: target.toISO(),
|
||||
});
|
||||
|
||||
lastOnThisDayDate = target;
|
||||
}
|
||||
}
|
||||
|
||||
@OnJob({ name: JobName.MEMORIES_CLEANUP, queue: QueueName.BACKGROUND_TASK })
|
||||
async onMemoriesCleanup() {
|
||||
await this.memoryRepository.cleanup();
|
||||
}
|
||||
|
||||
async search(auth: AuthDto, dto: MemorySearchDto) {
|
||||
const memories = await this.memoryRepository.search(auth.user.id, dto);
|
||||
return memories.map((memory) => mapMemory(memory));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -326,6 +326,10 @@ export type JobItem =
|
|||
| { name: JobName.QUEUE_DUPLICATE_DETECTION; data: IBaseJob }
|
||||
| { name: JobName.DUPLICATE_DETECTION; data: IEntityJob }
|
||||
|
||||
// Memories
|
||||
| { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob }
|
||||
| { name: JobName.MEMORIES_CREATE; data?: IBaseJob }
|
||||
|
||||
// Filesystem
|
||||
| { name: JobName.DELETE_FILES; data: IDeleteFilesJob }
|
||||
|
||||
|
|
@ -357,7 +361,11 @@ export type JobItem =
|
|||
| { name: JobName.NOTIFY_SIGNUP; data: INotifySignupJob }
|
||||
|
||||
// Version check
|
||||
| { name: JobName.VERSION_CHECK; data: IBaseJob };
|
||||
| { name: JobName.VERSION_CHECK; data: IBaseJob }
|
||||
|
||||
// Memories
|
||||
| { name: JobName.MEMORIES_CLEANUP; data?: IBaseJob }
|
||||
| { name: JobName.MEMORIES_CREATE; data?: IBaseJob };
|
||||
|
||||
export type VectorExtension = DatabaseExtension.VECTOR | DatabaseExtension.VECTORS;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue