0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-02-18 01:24:26 -05:00

feat: memories

refactor

chore: use heart as favorite icon

fix: linting
This commit is contained in:
Jason Rasmussen 2025-02-07 16:41:58 -05:00
parent 2c88ce8559
commit 14792c423c
No known key found for this signature in database
GPG key ID: 2EF24B77EAFA4A41
28 changed files with 584 additions and 70 deletions

View file

@ -354,6 +354,8 @@
"version_check_enabled_description": "Enable version check",
"version_check_implications": "The version check feature relies on periodic communication with github.com",
"version_check_settings": "Version Check",
"memory_cleanup_job": "Memory cleanup",
"memory_generate_job": "Memory generation",
"version_check_settings_description": "Enable/disable the new version notification",
"video_conversion_job": "Transcode videos",
"video_conversion_job_description": "Transcode videos for wider compatibility with browsers and devices"
@ -1076,6 +1078,8 @@
"remove_url": "Remove URL",
"remove_user": "Remove user",
"removed_api_key": "Removed API Key: {name}",
"removed_memory": "Removed memory",
"removed_photo_from_memory": "Removed photo from memory",
"removed_from_archive": "Removed from archive",
"removed_from_favorites": "Removed from favorites",
"removed_from_favorites_count": "{count, plural, other {Removed #}} from favorites",

View file

@ -262,7 +262,16 @@ class MemoriesApi {
}
/// Performs an HTTP 'GET /memories' operation and returns the [Response].
Future<Response> searchMemoriesWithHttpInfo() async {
/// Parameters:
///
/// * [DateTime] for_:
///
/// * [bool] isSaved:
///
/// * [bool] isTrashed:
///
/// * [MemoryType] type:
Future<Response> searchMemoriesWithHttpInfo({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async {
// ignore: prefer_const_declarations
final path = r'/memories';
@ -273,6 +282,19 @@ class MemoriesApi {
final headerParams = <String, String>{};
final formParams = <String, String>{};
if (for_ != null) {
queryParams.addAll(_queryParams('', 'for', for_));
}
if (isSaved != null) {
queryParams.addAll(_queryParams('', 'isSaved', isSaved));
}
if (isTrashed != null) {
queryParams.addAll(_queryParams('', 'isTrashed', isTrashed));
}
if (type != null) {
queryParams.addAll(_queryParams('', 'type', type));
}
const contentTypes = <String>[];
@ -287,8 +309,17 @@ class MemoriesApi {
);
}
Future<List<MemoryResponseDto>?> searchMemories() async {
final response = await searchMemoriesWithHttpInfo();
/// Parameters:
///
/// * [DateTime] for_:
///
/// * [bool] isSaved:
///
/// * [bool] isTrashed:
///
/// * [MemoryType] type:
Future<List<MemoryResponseDto>?> searchMemories({ DateTime? for_, bool? isSaved, bool? isTrashed, MemoryType? type, }) async {
final response = await searchMemoriesWithHttpInfo( for_: for_, isSaved: isSaved, isTrashed: isTrashed, type: type, );
if (response.statusCode >= HttpStatus.badRequest) {
throw ApiException(response.statusCode, await _decodeBodyBytes(response));
}

View file

@ -26,12 +26,16 @@ class ManualJobName {
static const personCleanup = ManualJobName._(r'person-cleanup');
static const tagCleanup = ManualJobName._(r'tag-cleanup');
static const userCleanup = ManualJobName._(r'user-cleanup');
static const memoryCleanup = ManualJobName._(r'memory-cleanup');
static const memoryCreate = ManualJobName._(r'memory-create');
/// List of all possible values in this [enum][ManualJobName].
static const values = <ManualJobName>[
personCleanup,
tagCleanup,
userCleanup,
memoryCleanup,
memoryCreate,
];
static ManualJobName? fromJson(dynamic value) => ManualJobNameTypeTransformer().decode(value);
@ -73,6 +77,8 @@ class ManualJobNameTypeTransformer {
case r'person-cleanup': return ManualJobName.personCleanup;
case r'tag-cleanup': return ManualJobName.tagCleanup;
case r'user-cleanup': return ManualJobName.userCleanup;
case r'memory-cleanup': return ManualJobName.memoryCleanup;
case r'memory-create': return ManualJobName.memoryCreate;
default:
if (!allowNull) {
throw ArgumentError('Unknown enum value to decode: $data');

View file

@ -17,11 +17,13 @@ class MemoryResponseDto {
required this.createdAt,
required this.data,
this.deletedAt,
this.hideAt,
required this.id,
required this.isSaved,
required this.memoryAt,
required this.ownerId,
this.seenAt,
this.showAt,
required this.type,
required this.updatedAt,
});
@ -40,6 +42,14 @@ class MemoryResponseDto {
///
DateTime? deletedAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? hideAt;
String id;
bool isSaved;
@ -56,6 +66,14 @@ class MemoryResponseDto {
///
DateTime? seenAt;
///
/// Please note: This property should have been non-nullable! Since the specification file
/// does not include a default value (using the "default:" property), however, the generated
/// source code must fall back to having a nullable type.
/// Consider adding a "default:" property in the specification file to hide this note.
///
DateTime? showAt;
MemoryType type;
DateTime updatedAt;
@ -66,11 +84,13 @@ class MemoryResponseDto {
other.createdAt == createdAt &&
other.data == data &&
other.deletedAt == deletedAt &&
other.hideAt == hideAt &&
other.id == id &&
other.isSaved == isSaved &&
other.memoryAt == memoryAt &&
other.ownerId == ownerId &&
other.seenAt == seenAt &&
other.showAt == showAt &&
other.type == type &&
other.updatedAt == updatedAt;
@ -81,16 +101,18 @@ class MemoryResponseDto {
(createdAt.hashCode) +
(data.hashCode) +
(deletedAt == null ? 0 : deletedAt!.hashCode) +
(hideAt == null ? 0 : hideAt!.hashCode) +
(id.hashCode) +
(isSaved.hashCode) +
(memoryAt.hashCode) +
(ownerId.hashCode) +
(seenAt == null ? 0 : seenAt!.hashCode) +
(showAt == null ? 0 : showAt!.hashCode) +
(type.hashCode) +
(updatedAt.hashCode);
@override
String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, type=$type, updatedAt=$updatedAt]';
String toString() => 'MemoryResponseDto[assets=$assets, createdAt=$createdAt, data=$data, deletedAt=$deletedAt, hideAt=$hideAt, id=$id, isSaved=$isSaved, memoryAt=$memoryAt, ownerId=$ownerId, seenAt=$seenAt, showAt=$showAt, type=$type, updatedAt=$updatedAt]';
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
@ -101,6 +123,11 @@ class MemoryResponseDto {
json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String();
} else {
// json[r'deletedAt'] = null;
}
if (this.hideAt != null) {
json[r'hideAt'] = this.hideAt!.toUtc().toIso8601String();
} else {
// json[r'hideAt'] = null;
}
json[r'id'] = this.id;
json[r'isSaved'] = this.isSaved;
@ -110,6 +137,11 @@ class MemoryResponseDto {
json[r'seenAt'] = this.seenAt!.toUtc().toIso8601String();
} else {
// json[r'seenAt'] = null;
}
if (this.showAt != null) {
json[r'showAt'] = this.showAt!.toUtc().toIso8601String();
} else {
// json[r'showAt'] = null;
}
json[r'type'] = this.type;
json[r'updatedAt'] = this.updatedAt.toUtc().toIso8601String();
@ -129,11 +161,13 @@ class MemoryResponseDto {
createdAt: mapDateTime(json, r'createdAt', r'')!,
data: OnThisDayDto.fromJson(json[r'data'])!,
deletedAt: mapDateTime(json, r'deletedAt', r''),
hideAt: mapDateTime(json, r'hideAt', r''),
id: mapValueOfType<String>(json, r'id')!,
isSaved: mapValueOfType<bool>(json, r'isSaved')!,
memoryAt: mapDateTime(json, r'memoryAt', r'')!,
ownerId: mapValueOfType<String>(json, r'ownerId')!,
seenAt: mapDateTime(json, r'seenAt', r''),
showAt: mapDateTime(json, r'showAt', r''),
type: MemoryType.fromJson(json[r'type'])!,
updatedAt: mapDateTime(json, r'updatedAt', r'')!,
);

View file

@ -3129,7 +3129,41 @@
"/memories": {
"get": {
"operationId": "searchMemories",
"parameters": [],
"parameters": [
{
"name": "for",
"required": false,
"in": "query",
"schema": {
"format": "date-time",
"type": "string"
}
},
{
"name": "isSaved",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "isTrashed",
"required": false,
"in": "query",
"schema": {
"type": "boolean"
}
},
{
"name": "type",
"required": false,
"in": "query",
"schema": {
"$ref": "#/components/schemas/MemoryType"
}
}
],
"responses": {
"200": {
"content": {
@ -9702,7 +9736,9 @@
"enum": [
"person-cleanup",
"tag-cleanup",
"user-cleanup"
"user-cleanup",
"memory-cleanup",
"memory-create"
],
"type": "string"
},
@ -9859,6 +9895,10 @@
"format": "date-time",
"type": "string"
},
"hideAt": {
"format": "date-time",
"type": "string"
},
"id": {
"type": "string"
},
@ -9876,6 +9916,10 @@
"format": "date-time",
"type": "string"
},
"showAt": {
"format": "date-time",
"type": "string"
},
"type": {
"allOf": [
{

View file

@ -631,11 +631,13 @@ export type MemoryResponseDto = {
createdAt: string;
data: OnThisDayDto;
deletedAt?: string;
hideAt?: string;
id: string;
isSaved: boolean;
memoryAt: string;
ownerId: string;
seenAt?: string;
showAt?: string;
"type": MemoryType;
updatedAt: string;
};
@ -2194,11 +2196,21 @@ export function reverseGeocode({ lat, lon }: {
...opts
}));
}
export function searchMemories(opts?: Oazapfts.RequestOpts) {
export function searchMemories({ $for, isSaved, isTrashed, $type }: {
$for?: string;
isSaved?: boolean;
isTrashed?: boolean;
$type?: MemoryType;
}, opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchJson<{
status: 200;
data: MemoryResponseDto[];
}>("/memories", {
}>(`/memories${QS.query(QS.explode({
"for": $for,
isSaved,
isTrashed,
"type": $type
}))}`, {
...opts
}));
}
@ -3506,7 +3518,9 @@ export enum EntityType {
export enum ManualJobName {
PersonCleanup = "person-cleanup",
TagCleanup = "tag-cleanup",
UserCleanup = "user-cleanup"
UserCleanup = "user-cleanup",
MemoryCleanup = "memory-cleanup",
MemoryCreate = "memory-create"
}
export enum JobName {
ThumbnailGeneration = "thumbnailGeneration",

View file

@ -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
View file

@ -226,11 +226,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>;
}

View file

@ -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,

View file

@ -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;

View file

@ -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;
}

View file

@ -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',

View file

@ -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"`);
}
}

View file

@ -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

View file

@ -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();

View file

@ -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 },

View file

@ -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 },

View file

@ -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([]);
});
});

View file

@ -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));
}

View file

@ -325,6 +325,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 }
@ -356,7 +360,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;

View file

@ -13,25 +13,45 @@
import DeleteAssets from '$lib/components/photos-page/actions/delete-assets.svelte';
import DownloadAction from '$lib/components/photos-page/actions/download-action.svelte';
import FavoriteAction from '$lib/components/photos-page/actions/favorite-action.svelte';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import AssetSelectControlBar from '$lib/components/photos-page/asset-select-control-bar.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/button-context-menu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/menu-option.svelte';
import ControlAppBar from '$lib/components/shared-components/control-app-bar.svelte';
import GalleryViewer from '$lib/components/shared-components/gallery-viewer/gallery-viewer.svelte';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import {
notificationController,
NotificationType,
} from '$lib/components/shared-components/notification/notification';
import { AppRoute, QueryParameter } from '$lib/constants';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { type Viewport } from '$lib/stores/assets.store';
import { memoryStore } from '$lib/stores/memory.store';
import { loadMemories, memoryStore } from '$lib/stores/memory.store';
import { locale } from '$lib/stores/preferences.store';
import { preferences } from '$lib/stores/user.store';
import { getAssetThumbnailUrl, handlePromiseError, memoryLaneTitle } from '$lib/utils';
import { cancelMultiselect } from '$lib/utils/asset-utils';
import { fromLocalDateTime } from '$lib/utils/timeline-util';
import { AssetMediaSize, getMemoryLane, type AssetResponseDto, type MemoryLaneResponseDto } from '@immich/sdk';
import {
AssetMediaSize,
deleteMemory,
removeMemoryAssets,
updateMemory,
type AssetResponseDto,
type MemoryResponseDto,
} from '@immich/sdk';
import { IconButton } from '@immich/ui';
import {
mdiCardsOutline,
mdiChevronDown,
mdiChevronLeft,
mdiChevronRight,
mdiChevronUp,
mdiDotsVertical,
mdiHeart,
mdiHeartOutline,
mdiImageMinusOutline,
mdiImageSearch,
mdiPause,
mdiPlay,
@ -45,9 +65,6 @@
import { tweened } from 'svelte/motion';
import { derived as storeDerived } from 'svelte/store';
import { fade } from 'svelte/transition';
import { preferences } from '$lib/stores/user.store';
import TagAction from '$lib/components/photos-page/actions/tag-action.svelte';
import { AssetInteraction } from '$lib/stores/asset-interaction.svelte';
type MemoryIndex = {
memoryIndex: number;
@ -55,20 +72,20 @@
};
type MemoryAsset = MemoryIndex & {
memory: MemoryLaneResponseDto;
memory: MemoryResponseDto;
asset: AssetResponseDto;
previousMemory?: MemoryLaneResponseDto;
previousMemory?: MemoryResponseDto;
previous?: MemoryAsset;
next?: MemoryAsset;
nextMemory?: MemoryLaneResponseDto;
nextMemory?: MemoryResponseDto;
};
let memoryGallery: HTMLElement | undefined = $state();
let memoryWrapper: HTMLElement | undefined = $state();
let galleryInView = $state(false);
let paused = $state(false);
let current: MemoryAsset | undefined = $state(undefined);
// let memories: MemoryAsset[] = [];
let current = $state<MemoryAsset | undefined>(undefined);
let isSaved = $derived(current?.memory.isSaved);
let resetPromise = $state(Promise.resolve());
const { isViewing } = assetViewingStore;
@ -168,6 +185,7 @@
}
current.memory.assets = current.memory.assets;
};
const handleRemove = (ids: string[]) => {
if (!current) {
return;
@ -186,13 +204,65 @@
current = loadFromParams($memories, $page);
};
const handleDeleteMemoryAsset = async (current?: MemoryAsset) => {
if (!current) {
return;
}
if (current.memory.assets.length === 1) {
return handleDeleteMemory(current);
}
if (current.previous) {
current.previous.next = current.next;
}
if (current.next) {
current.next.previous = current.previous;
}
current.memory.assets = current.memory.assets.filter((asset) => asset.id !== current.asset.id);
$memoryStore = $memoryStore;
await removeMemoryAssets({ id: current.memory.id, bulkIdsDto: { ids: [current.asset.id] } });
};
const handleDeleteMemory = async (current?: MemoryAsset) => {
if (!current) {
return;
}
await deleteMemory({ id: current.memory.id });
notificationController.show({ message: $t('removed_memory'), type: NotificationType.Info });
await loadMemories();
init();
};
const handleSaveMemory = async (current?: MemoryAsset) => {
if (!current) {
return;
}
current.memory.isSaved = !current.memory.isSaved;
await updateMemory({
id: current.memory.id,
memoryUpdateDto: {
isSaved: current.memory.isSaved,
},
});
notificationController.show({
message: current.memory.isSaved ? $t('added_to_favorites') : $t('removed_from_favorites'),
type: NotificationType.Info,
});
};
onMount(async () => {
if (!$memoryStore) {
const localTime = new Date();
$memoryStore = await getMemoryLane({
month: localTime.getMonth() + 1,
day: localTime.getDate(),
});
await loadMemories();
}
init();
@ -268,7 +338,7 @@
{#snippet leading()}
{#if current}
<p class="text-lg">
{$memoryLaneTitle(current.memory.yearsAgo)}
{$memoryLaneTitle(current.memory)}
</p>
{/if}
{/snippet}
@ -352,7 +422,7 @@
{#if current.previousMemory}
<div class="absolute bottom-4 right-4 text-left text-white">
<p class="text-xs font-semibold text-gray-200">{$t('previous').toUpperCase()}</p>
<p class="text-xl">{$memoryLaneTitle(current.previousMemory.yearsAgo)}</p>
<p class="text-xl">{$memoryLaneTitle(current.previousMemory)}</p>
</div>
{/if}
</button>
@ -374,17 +444,63 @@
{/key}
<div
class="absolute bottom-6 right-6 transition-all"
class="absolute bottom-0 right-0 p-2 transition-all flex h-full justify-between flex-col items-end gap-2"
class:opacity-0={galleryInView}
class:opacity-100={!galleryInView}
>
<CircleIconButton
href="{AppRoute.PHOTOS}?at={current.asset.id}"
icon={mdiImageSearch}
title={$t('view_in_timeline')}
color="light"
onclick={() => {}}
/>
<div class="flex">
<IconButton
icon={isSaved ? mdiHeart : mdiHeartOutline}
shape="round"
variant="ghost"
size="giant"
color="secondary"
aria-label={isSaved ? $t('unfavorite') : $t('favorite')}
onclick={() => handleSaveMemory(current)}
class="text-white dark:text-white"
/>
<!-- <IconButton
icon={mdiShareVariantOutline}
shape="round"
variant="ghost"
size="giant"
color="secondary"
aria-label={$t('share')}
/> -->
<ButtonContextMenu
icon={mdiDotsVertical}
title={$t('menu')}
onclick={() => handleAction('pause')}
direction="left"
align="bottom-right"
class="text-white dark:text-white"
>
<MenuOption
onClick={() => handleDeleteMemory(current)}
text={'Remove memory'}
icon={mdiCardsOutline}
/>
<MenuOption
onClick={() => handleDeleteMemoryAsset(current)}
text={'Remove photo from this memory'}
icon={mdiImageMinusOutline}
/>
<!-- shortcut={{ key: 'l', shift: shared }} -->
</ButtonContextMenu>
</div>
<div>
<IconButton
href="{AppRoute.PHOTOS}?at={current.asset.id}"
icon={mdiImageSearch}
aria-label={$t('view_in_timeline')}
color="secondary"
variant="ghost"
shape="round"
size="giant"
class="text-white dark:text-white"
/>
</div>
</div>
<!-- CONTROL BUTTONS -->
{#if current.previous}
@ -449,7 +565,7 @@
{#if current.nextMemory}
<div class="absolute bottom-4 left-4 text-left text-white">
<p class="text-xs font-semibold text-gray-200">{$t('up_next').toUpperCase()}</p>
<p class="text-xl">{$memoryLaneTitle(current.nextMemory.yearsAgo)}</p>
<p class="text-xl">{$memoryLaneTitle(current.nextMemory)}</p>
</div>
{/if}
</button>

View file

@ -2,20 +2,18 @@
import { resizeObserver } from '$lib/actions/resize-observer';
import Icon from '$lib/components/elements/icon.svelte';
import { AppRoute, QueryParameter } from '$lib/constants';
import { memoryStore } from '$lib/stores/memory.store';
import { loadMemories, memoryStore } from '$lib/stores/memory.store';
import { getAssetThumbnailUrl, memoryLaneTitle } from '$lib/utils';
import { getAltText } from '$lib/utils/thumbnail-util';
import { getMemoryLane } from '@immich/sdk';
import { mdiChevronLeft, mdiChevronRight } from '@mdi/js';
import { onMount } from 'svelte';
import { fade } from 'svelte/transition';
import { t } from 'svelte-i18n';
import { fade } from 'svelte/transition';
let shouldRender = $derived($memoryStore?.length > 0);
onMount(async () => {
const localTime = new Date();
$memoryStore = await getMemoryLane({ month: localTime.getMonth() + 1, day: localTime.getDate() });
await loadMemories();
});
let memoryLaneElement: HTMLElement | undefined = $state();
@ -71,7 +69,7 @@
</div>
{/if}
<div class="inline-block" use:resizeObserver={({ width }) => (innerWidth = width)}>
{#each $memoryStore as memory (memory.yearsAgo)}
{#each $memoryStore as memory}
{#if memory.assets.length > 0}
<a
class="memory-card relative mr-8 inline-block aspect-video h-[215px] rounded-xl"
@ -84,7 +82,7 @@
draggable="false"
/>
<p class="absolute bottom-2 left-4 z-10 text-lg text-white">
{$memoryLaneTitle(memory.yearsAgo)}
{$memoryLaneTitle(memory)}
</p>
<div
class="absolute left-0 top-0 z-0 h-full w-full rounded-xl bg-gradient-to-t from-black/40 via-transparent to-transparent transition-all hover:bg-black/20"

View file

@ -1,22 +1,23 @@
<script lang="ts">
import { clickOutside } from '$lib/actions/click-outside';
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { shortcuts } from '$lib/actions/shortcut';
import CircleIconButton, {
type Color,
type Padding,
} from '$lib/components/elements/buttons/circle-icon-button.svelte';
import ContextMenu from '$lib/components/shared-components/context-menu/context-menu.svelte';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import {
getContextMenuPositionFromBoundingRect,
getContextMenuPositionFromEvent,
type Align,
} from '$lib/utils/context-menu';
import { generateId } from '$lib/utils/generate-id';
import { contextMenuNavigation } from '$lib/actions/context-menu-navigation';
import { optionClickCallbackStore, selectedIdStore } from '$lib/stores/context-menu.store';
import { clickOutside } from '$lib/actions/click-outside';
import { shortcuts } from '$lib/actions/shortcut';
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
interface Props {
type Props = {
icon: string;
title: string;
/**
@ -36,7 +37,7 @@
buttonClass?: string | undefined;
hideContent?: boolean;
children?: Snippet;
}
} & HTMLAttributes<HTMLDivElement>;
let {
icon,
@ -49,6 +50,7 @@
buttonClass = undefined,
hideContent = false,
children,
...restProps
}: Props = $props();
let isOpen = $state(false);
@ -129,6 +131,7 @@
}}
use:clickOutside={{ onOutclick: closeDropdown }}
onresize={onResize}
{...restProps}
>
<div bind:this={buttonContainer}>
<CircleIconButton

View file

@ -1,4 +1,11 @@
import type { MemoryLaneResponseDto } from '@immich/sdk';
import { asLocalTimeISO } from '$lib/utils/date-time';
import { searchMemories, type MemoryResponseDto } from '@immich/sdk';
import { DateTime } from 'luxon';
import { writable } from 'svelte/store';
export const memoryStore = writable<MemoryLaneResponseDto[]>();
export const memoryStore = writable<MemoryResponseDto[]>();
export const loadMemories = async () => {
const memories = await searchMemories({ $for: asLocalTimeISO(DateTime.now()) });
memoryStore.set(memories);
};

View file

@ -6,6 +6,7 @@ import {
AssetJobName,
AssetMediaSize,
JobName,
MemoryType,
finishOAuth,
getAssetOriginalPath,
getAssetPlaybackPath,
@ -16,6 +17,7 @@ import {
linkOAuthAccount,
startOAuth,
unlinkOAuthAccount,
type MemoryResponseDto,
type PersonResponseDto,
type SharedLinkResponseDto,
type UserResponseDto,
@ -320,7 +322,14 @@ export const handlePromiseError = <T>(promise: Promise<T>): void => {
};
export const memoryLaneTitle = derived(t, ($t) => {
return (yearsAgo: number) => $t('years_ago', { values: { years: yearsAgo } });
return (memory: MemoryResponseDto) => {
const now = new Date();
if (memory.type === MemoryType.OnThisDay) {
return $t('years_ago', { values: { years: now.getFullYear() - memory.data.year } });
}
return $t('unknown');
};
});
export const withError = async <T>(fn: () => Promise<T>): Promise<[undefined, T] | [unknown, undefined]> => {

View file

@ -1,4 +1,4 @@
export type Align = 'middle' | 'top-left' | 'top-right';
export type Align = 'middle' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
export type ContextMenuPosition = { x: number; y: number };
@ -28,5 +28,11 @@ export const getContextMenuPositionFromBoundingRect = (rect: DOMRect, align: Ali
case 'top-right': {
return { x: rect.x + rect.width, y: rect.y };
}
case 'bottom-left': {
return { x: rect.x, y: rect.y + rect.height };
}
case 'bottom-right': {
return { x: rect.x + rect.width, y: rect.y + rect.height };
}
}
};

View file

@ -77,3 +77,11 @@ export const getAlbumDateRange = (album: { startDate?: string; endDate?: string
return '';
};
/**
* Use this to convert from "5pm EST" to "5pm UTC"
*
* Useful with some APIs where you want to query by "today", but the values in the database are stored as UTC
*/
export const asLocalTimeISO = (date: DateTime<true>) =>
(date.setZone('utc', { keepLocalTime: true }) as DateTime<true>).toISO();

View file

@ -44,6 +44,8 @@
{ title: $t('admin.person_cleanup_job'), value: ManualJobName.PersonCleanup },
{ title: $t('admin.tag_cleanup_job'), value: ManualJobName.TagCleanup },
{ title: $t('admin.user_cleanup_job'), value: ManualJobName.UserCleanup },
{ title: $t('admin.memory_cleanup_job'), value: ManualJobName.MemoryCleanup },
{ title: $t('admin.memory_generate_job'), value: ManualJobName.MemoryCreate },
].map(({ value, title }) => ({ id: value, label: title, value }));
const handleCancel = () => (isOpen = false);