Compare commits

...

12 Commits

Author SHA1 Message Date
Min Idzelis
053dd490b4 fix test 2025-06-15 03:40:31 +00:00
Min Idzelis
f4f81341da tests 2025-06-15 03:22:32 +00:00
Min Idzelis
3e66913cf8 chore: lint 2025-06-15 03:16:39 +00:00
Min Idzelis
504309eff5 chore: make sql 2025-06-15 03:04:23 +00:00
Min Idzelis
b44abf5b4b Merge remote-tracking branch 'origin/main' into timeline_events 2025-06-15 02:58:04 +00:00
Min Idzelis
c76e8da173 chore: cleanup 2025-06-15 02:47:18 +00:00
Min Idzelis
9cc2189ef7 chore: remove unused code and fix test expectations
- Remove unused activityManager import from asset viewer components
- Remove unused function stub in activity manager
- Fix album service test expectations for emit parameters
- Clean up formatting in person repository mock
- Update trash service tests for emit event changes
2025-06-15 02:25:42 +00:00
Min Idzelis
6b87efe7a3 feat(web): improve websocket filtering and add restored assets support
- Refactor websocket support to use modular filter functions
- Add support for on_asset_restore events
- Improve handling of asset updates with proper filtering for visibility, favorites, trash, tags, albums, and persons
- Add null checks in timeline manager for empty arrays
2025-06-15 02:25:18 +00:00
Min Idzelis
7b75da1f10 refactor(server): change asset update events to send IDs instead of full assets
- Change on_asset_update event to send asset IDs array instead of full AssetResponseDto
- Add asset.update event emission in asset service for update operations
- Update notification handlers to work with asset IDs
- Improve update logic to avoid duplicate events when metadata is updated
- Update frontend websocket types to match new event format
2025-06-15 02:24:06 +00:00
Min Idzelis
a7559f0691 feat(server): add websocket events for activity changes
- Add 'activity.change' event to event repository
- Emit event when new activity (reaction/comment) is created
- Add notification handler to broadcast activity changes to relevant users
- Update frontend websocket types to include on_activity_change event
- Update tests to mock album repository calls
2025-06-15 02:23:09 +00:00
Min Idzelis
6f2f295cf3 refactor(server): clean up asset repository and add getTrashedIds method
- Remove redundant return statement in asset repository update method
- Add getTrashedIds method to trash repository for retrieving trashed asset IDs by user
2025-06-15 02:22:37 +00:00
Min Idzelis
b3d080f6e8 feat(server,web): add websocket events for album updates
and person face changes
2025-06-11 10:52:43 +00:00
25 changed files with 602 additions and 153 deletions

View File

@@ -219,7 +219,7 @@ describe('/timeline', () => {
const { status, body } = await request(app) const { status, body } = await request(app)
.get('/timeline/bucket') .get('/timeline/bucket')
.set('Authorization', `Bearer ${timeBucketUser.accessToken}`) .set('Authorization', `Bearer ${timeBucketUser.accessToken}`)
.query({ timeBucket: '1970-02-01T00:00:00.000Z', isTrashed: true }); .query({ timeBucket: '1970-02-01', isTrashed: true });
expect(status).toBe(200); expect(status).toBe(200);

View File

@@ -216,7 +216,11 @@ export const utils = {
websocket websocket
.on('connect', () => resolve(websocket)) .on('connect', () => resolve(websocket))
.on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id })) .on('on_upload_success', (data: AssetResponseDto) => onEvent({ event: 'assetUpload', id: data.id }))
.on('on_asset_update', (data: AssetResponseDto) => onEvent({ event: 'assetUpdate', id: data.id })) .on('on_asset_update', (assetId: string[]) => {
for (const id of assetId) {
onEvent({ event: 'assetUpdate', id });
}
})
.on('on_asset_hidden', (assetId: string) => onEvent({ event: 'assetHidden', id: assetId })) .on('on_asset_hidden', (assetId: string) => onEvent({ event: 'assetHidden', id: assetId }))
.on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId })) .on('on_asset_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId })) .on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))

View File

@@ -279,6 +279,15 @@ where
"asset_faces"."personId" = $1 "asset_faces"."personId" = $1
and "asset_faces"."deletedAt" is null and "asset_faces"."deletedAt" is null
-- PersonRepository.getAssetPersonByFaceId
select
"asset_faces"."assetId",
"asset_faces"."personId"
from
"asset_faces"
where
"asset_faces"."id" = $1
-- PersonRepository.getLatestFaceDate -- PersonRepository.getLatestFaceDate
select select
max("asset_job_status"."facesRecognizedAt")::text as "latestDate" max("asset_job_status"."facesRecognizedAt")::text as "latestDate"

View File

@@ -403,8 +403,6 @@ export class AssetRepository {
.$call((qb) => qb.select(withFacesAndPeople)) .$call((qb) => qb.select(withFacesAndPeople))
.executeTakeFirst(); .executeTakeFirst();
} }
return this.getById(asset.id, { exifInfo: true, faces: { person: true } });
} }
async remove(asset: { id: string }): Promise<void> { async remove(asset: { id: string }): Promise<void> {

View File

@@ -47,11 +47,20 @@ type EventMap = {
]; ];
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }]; 'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// activity events
'activity.change': [{ recipientId: string[]; userId: string; albumId: string; assetId: string | null }];
// album events // album events
'album.update': [{ id: string; recipientId: string }]; 'album.update': [
{ id: string; recipientId: string[]; assetId: string[]; userId: string; status: 'added' | 'removed' },
];
'album.invite': [{ id: string; userId: string }]; 'album.invite': [{ id: string; userId: string }];
// asset events // asset events
'asset.update': [{ assetIds: string[]; userId: string }];
'asset.person': [
{ assetId: string; userId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' },
];
'asset.tag': [{ assetId: string }]; 'asset.tag': [{ assetId: string }];
'asset.untag': [{ assetId: string }]; 'asset.untag': [{ assetId: string }];
'asset.hide': [{ assetId: string; userId: string }]; 'asset.hide': [{ assetId: string; userId: string }];
@@ -97,9 +106,12 @@ export type ArgsOf<T extends EmitEvent> = EventMap[T];
export interface ClientEventMap { export interface ClientEventMap {
on_upload_success: [AssetResponseDto]; on_upload_success: [AssetResponseDto];
on_user_delete: [string]; on_user_delete: [string];
on_activity_change: [{ albumId: string; assetId: string | null }];
on_album_update: [{ albumId: string; assetId: string[]; status: 'added' | 'removed' }];
on_asset_person: [{ assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }];
on_asset_delete: [string]; on_asset_delete: [string];
on_asset_trash: [string[]]; on_asset_trash: [string[]];
on_asset_update: [AssetResponseDto]; on_asset_update: [string[]];
on_asset_hidden: [string]; on_asset_hidden: [string];
on_asset_restore: [string[]]; on_asset_restore: [string[]];
on_asset_stack_update: string[]; on_asset_stack_update: string[];

View File

@@ -483,6 +483,15 @@ export class PersonRepository {
.executeTakeFirst(); .executeTakeFirst();
} }
@GenerateSql({ params: [DummyValue.UUID] })
async getAssetPersonByFaceId(id: string) {
return this.db
.selectFrom('asset_faces')
.select(['asset_faces.assetId', 'asset_faces.personId'])
.where('asset_faces.id', '=', id)
.executeTakeFirst();
}
@GenerateSql() @GenerateSql()
async getLatestFaceDate(): Promise<string | undefined> { async getLatestFaceDate(): Promise<string | undefined> {
const result = (await this.db const result = (await this.db

View File

@@ -11,6 +11,15 @@ export class TrashRepository {
return this.db.selectFrom('assets').select(['id']).where('status', '=', AssetStatus.DELETED).stream(); return this.db.selectFrom('assets').select(['id']).where('status', '=', AssetStatus.DELETED).stream();
} }
getTrashedIds(userId: string): AsyncIterableIterator<{ id: string }> {
return this.db
.selectFrom('assets')
.select(['id'])
.where('ownerId', '=', userId)
.where('status', '=', AssetStatus.TRASHED)
.stream();
}
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async restore(userId: string): Promise<number> { async restore(userId: string): Promise<number> {
const { numUpdatedRows } = await this.db const { numUpdatedRows } = await this.db

View File

@@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto'; import { ReactionType } from 'src/dtos/activity.dto';
import { ActivityService } from 'src/services/activity.service'; import { ActivityService } from 'src/services/activity.service';
import { albumStub } from 'test/fixtures/album.stub';
import { factory, newUuid, newUuids } from 'test/small.factory'; import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
@@ -79,6 +80,11 @@ describe(ActivityService.name, () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity); mocks.activity.create.mockResolvedValue(activity);
mocks.album.getById.mockResolvedValue({
...albumStub.empty,
owner: factory.user({ id: userId }),
albumUsers: [],
});
await sut.create(factory.auth({ user: { id: userId } }), { await sut.create(factory.auth({ user: { id: userId } }), {
albumId, albumId,
@@ -115,6 +121,11 @@ describe(ActivityService.name, () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId])); mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity); mocks.activity.create.mockResolvedValue(activity);
mocks.activity.search.mockResolvedValue([]); mocks.activity.search.mockResolvedValue([]);
mocks.album.getById.mockResolvedValue({
...albumStub.empty,
owner: factory.user({ id: userId }),
albumUsers: [],
});
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE }); await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { Activity } from 'src/database'; import { Activity } from 'src/database';
import { import {
ActivityCreateDto, ActivityCreateDto,
@@ -58,11 +58,24 @@ export class ActivityService extends BaseService {
} }
if (!activity) { if (!activity) {
const album = await this.albumRepository.getById(common.albumId, { withAssets: false });
if (!album) {
throw new BadRequestException('Album not found');
}
activity = await this.activityRepository.create({ activity = await this.activityRepository.create({
...common, ...common,
isLiked: dto.type === ReactionType.LIKE, isLiked: dto.type === ReactionType.LIKE,
comment: dto.comment, comment: dto.comment,
}); });
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
);
await this.eventRepository.emit('activity.change', {
recipientId: allUsersExceptUs,
userId: common.userId,
albumId: activity.albumId,
assetId: activity.assetId,
});
} }
return { duplicate, value: mapActivity(activity) }; return { duplicate, value: mapActivity(activity) };

View File

@@ -664,7 +664,10 @@ describe(AlbumService.name, () => {
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']); expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', { expect(mocks.event.emit).toHaveBeenCalledWith('album.update', {
id: 'album-123', id: 'album-123',
recipientId: 'admin_id', userId: 'user-id',
assetId: ['asset-1', 'asset-2', 'asset-3'],
recipientId: ['admin_id'],
status: 'added',
}); });
}); });

View File

@@ -178,9 +178,13 @@ export class AlbumService extends BaseService {
(userId) => userId !== auth.user.id, (userId) => userId !== auth.user.id,
); );
for (const recipientId of allUsersExceptUs) { await this.eventRepository.emit('album.update', {
await this.eventRepository.emit('album.update', { id, recipientId }); id,
} userId: auth.user.id,
assetId: dto.ids,
recipientId: allUsersExceptUs,
status: 'added',
});
} }
return results; return results;
@@ -200,7 +204,16 @@ export class AlbumService extends BaseService {
if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) { if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
await this.albumRepository.updateThumbnails(); await this.albumRepository.updateThumbnails();
} }
const allUsersExceptUs = [...album.albumUsers.map(({ user }) => user.id), album.owner.id].filter(
(userId) => userId !== auth.user.id,
);
await this.eventRepository.emit('album.update', {
id,
userId: auth.user.id,
assetId: dto.ids,
recipientId: allUsersExceptUs,
status: 'removed',
});
return results; return results;
} }

View File

@@ -93,9 +93,26 @@ export class AssetService extends BaseService {
} }
} }
await this.updateMetadata({ id, description, dateTimeOriginal, latitude, longitude, rating }); const metadataUpdated = await this.updateMetadata({
id,
description,
dateTimeOriginal,
latitude,
longitude,
rating,
});
const asset = await this.assetRepository.update({ id, ...rest }); const updatedAsset = await this.assetRepository.update({ id, ...rest });
// If update returned undefined (no changes), fetch the asset
// Match the relations that update() returns when it does update
const asset = updatedAsset ?? (await this.assetRepository.getById(id, { exifInfo: true, faces: { person: true } }));
if (!metadataUpdated && updatedAsset) {
// updateMetadata will send an event, but assetRepository.update() won't.
// to prevent doubles, only send an event if asset was updated
await this.eventRepository.emit('asset.update', { assetIds: [id], userId: auth.user.id });
}
if (previousMotion && asset) { if (previousMotion && asset) {
await onAfterUnlink(repos, { await onAfterUnlink(repos, {
@@ -113,35 +130,27 @@ export class AssetService extends BaseService {
} }
async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> { async updateAll(auth: AuthDto, dto: AssetBulkUpdateDto): Promise<void> {
const { ids, description, dateTimeOriginal, latitude, longitude, ...options } = dto; const { ids, description, dateTimeOriginal, latitude, longitude, rating, ...rest } = dto;
await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids }); await this.requireAccess({ auth, permission: Permission.ASSET_UPDATE, ids });
if ( const metadataUpdated = await this.updateAllMetadata(ids, {
description !== undefined || description,
dateTimeOriginal !== undefined || dateTimeOriginal,
latitude !== undefined || latitude,
longitude !== undefined longitude,
) { rating,
await this.assetRepository.updateAllExif(ids, { description, dateTimeOriginal, latitude, longitude }); });
await this.jobRepository.queueAll(
ids.map((id) => ({
name: JobName.SIDECAR_WRITE,
data: { id, description, dateTimeOriginal, latitude, longitude },
})),
);
}
if ( if (rest.visibility !== undefined || rest.isFavorite !== undefined || rest.duplicateId !== undefined) {
options.visibility !== undefined || await this.assetRepository.updateAll(ids, rest);
options.isFavorite !== undefined ||
options.duplicateId !== undefined ||
options.rating !== undefined
) {
await this.assetRepository.updateAll(ids, options);
if (options.visibility === AssetVisibility.LOCKED) { if (rest.visibility === AssetVisibility.LOCKED) {
await this.albumRepository.removeAssetsFromAll(ids); await this.albumRepository.removeAssetsFromAll(ids);
} }
if (!metadataUpdated) {
// If no metadata was updated, we still need to emit an event for the bulk update
await this.eventRepository.emit('asset.update', { assetIds: ids, userId: auth.user.id });
}
} }
} }
@@ -290,6 +299,26 @@ export class AssetService extends BaseService {
if (Object.keys(writes).length > 0) { if (Object.keys(writes).length > 0) {
await this.assetRepository.upsertExif({ assetId: id, ...writes }); await this.assetRepository.upsertExif({ assetId: id, ...writes });
await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } }); await this.jobRepository.queue({ name: JobName.SIDECAR_WRITE, data: { id, ...writes } });
} return true;
}
return false;
}
private async updateAllMetadata(
ids: string[],
dto: Pick<AssetBulkUpdateDto, 'description' | 'dateTimeOriginal' | 'latitude' | 'longitude' | 'rating'>,
) {
const { description, dateTimeOriginal, latitude, longitude, rating } = dto;
const writes = _.omitBy({ description, dateTimeOriginal, latitude, longitude, rating }, _.isUndefined);
if (Object.keys(writes).length > 0) {
await this.assetRepository.updateAllExif(ids, writes);
const jobs: JobItem[] = ids.map((id) => ({
name: JobName.SIDECAR_WRITE,
data: { id, ...writes },
}));
await this.jobRepository.queueAll(jobs);
return true;
}
return false;
} }
} }

View File

@@ -4,7 +4,6 @@ import { AlbumUser } from 'src/database';
import { SystemConfigDto } from 'src/dtos/system-config.dto'; import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum'; import { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { NotificationService } from 'src/services/notification.service'; import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub'; import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
@@ -154,7 +153,7 @@ describe(NotificationService.name, () => {
describe('onAlbumUpdateEvent', () => { describe('onAlbumUpdateEvent', () => {
it('should queue notify album update event', async () => { it('should queue notify album update event', async () => {
await sut.onAlbumUpdate({ id: 'album', recipientId: '42' }); await sut.onAlbumUpdate({ id: 'album', recipientId: ['42'], userId: '', assetId: [], status: 'added' });
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id: 'album', recipientId: '42', delay: 300_000 }, data: { id: 'album', recipientId: '42', delay: 300_000 },
@@ -499,7 +498,13 @@ describe(NotificationService.name, () => {
}); });
it('should add new recipients for new images if job is already queued', async () => { it('should add new recipients for new images if job is already queued', async () => {
await sut.onAlbumUpdate({ id: '1', recipientId: '2' } as INotifyAlbumUpdateJob); await sut.onAlbumUpdate({
id: '1',
recipientId: ['2'],
userId: '',
assetId: [],
status: 'added',
});
expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2'); expect(mocks.job.removeJob).toHaveBeenCalledWith(JobName.NOTIFY_ALBUM_UPDATE, '1/2');
expect(mocks.job.queue).toHaveBeenCalledWith({ expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,

View File

@@ -1,6 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators'; import { OnEvent, OnJob } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto'; import { AuthDto } from 'src/dtos/auth.dto';
import { import {
mapNotification, mapNotification,
@@ -128,6 +127,20 @@ export class NotificationService extends BaseService {
} }
} }
@OnEvent({ name: 'activity.change' })
onActivityChange({ recipientId, assetId, userId, albumId }: ArgOf<'activity.change'>) {
for (const recipient of recipientId) {
this.eventRepository.clientSend('on_activity_change', recipient, { albumId, assetId });
}
this.eventRepository.clientSend('on_activity_change', userId, { albumId, assetId });
}
@OnEvent({ name: 'asset.person' })
onAssetPerson({ assetId, userId, personId, status }: ArgOf<'asset.person'>) {
this.eventRepository.clientSend('on_asset_person', userId, { assetId, personId, status });
}
@OnEvent({ name: 'asset.hide' }) @OnEvent({ name: 'asset.hide' })
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) { onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
this.eventRepository.clientSend('on_asset_hidden', userId, assetId); this.eventRepository.clientSend('on_asset_hidden', userId, assetId);
@@ -153,16 +166,17 @@ export class NotificationService extends BaseService {
this.eventRepository.clientSend('on_asset_trash', userId, assetIds); this.eventRepository.clientSend('on_asset_trash', userId, assetIds);
} }
@OnEvent({ name: 'asset.update' })
onAssetUpdate({ assetIds, userId }: ArgOf<'asset.update'>) {
this.eventRepository.clientSend('on_asset_update', userId, assetIds);
}
@OnEvent({ name: 'asset.metadataExtracted' }) @OnEvent({ name: 'asset.metadataExtracted' })
async onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) { onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) {
if (source !== 'sidecar-write') { if (source !== 'sidecar-write') {
return; return;
} }
this.eventRepository.clientSend('on_asset_update', userId, [assetId]);
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
if (asset) {
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
}
} }
@OnEvent({ name: 'assets.restore' }) @OnEvent({ name: 'assets.restore' })
@@ -198,12 +212,23 @@ export class NotificationService extends BaseService {
} }
@OnEvent({ name: 'album.update' }) @OnEvent({ name: 'album.update' })
async onAlbumUpdate({ id, recipientId }: ArgOf<'album.update'>) { async onAlbumUpdate({ id, recipientId, userId, assetId, status }: ArgOf<'album.update'>) {
if (status === 'added') {
for (const recipient of recipientId) {
await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`); await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`);
await this.jobRepository.queue({ await this.jobRepository.queue({
name: JobName.NOTIFY_ALBUM_UPDATE, name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs }, data: { id, recipientId: recipient, delay: NotificationService.albumUpdateEmailDelayMs },
}); });
this.eventRepository.clientSend('on_album_update', recipient, { albumId: id, assetId, status });
}
} else if (status === 'removed') {
for (const recipient of recipientId) {
this.eventRepository.clientSend('on_album_update', recipient, { albumId: id, assetId, status });
}
}
this.eventRepository.clientSend('on_album_update', userId, { albumId: id, assetId, status });
} }
@OnEvent({ name: 'album.invite' }) @OnEvent({ name: 'album.invite' })

View File

@@ -627,11 +627,28 @@ export class PersonService extends BaseService {
boundingBoxY2: dto.y + dto.height, boundingBoxY2: dto.y + dto.height,
sourceType: SourceType.MANUAL, sourceType: SourceType.MANUAL,
}); });
await this.eventRepository.emit('asset.person', {
assetId: dto.assetId,
userId: auth.user.id,
personId: dto.personId,
status: 'created',
});
} }
async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise<void> { async deleteFace(auth: AuthDto, id: string, dto: AssetFaceDeleteDto): Promise<void> {
await this.requireAccess({ auth, permission: Permission.FACE_DELETE, ids: [id] }); await this.requireAccess({ auth, permission: Permission.FACE_DELETE, ids: [id] });
const assetPerson = await this.personRepository.getAssetPersonByFaceId(id);
if (!assetPerson) {
throw new NotFoundException('Asset face not found');
}
return dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id); await (dto.force ? this.personRepository.deleteAssetFace(id) : this.personRepository.softDeleteAssetFaces(id));
await this.eventRepository.emit('asset.person', {
userId: auth.user.id,
assetId: assetPerson.assetId,
personId: assetPerson.personId ?? undefined,
status: dto.force ? 'removed' : 'removed_soft',
});
} }
} }

View File

@@ -50,30 +50,28 @@ describe(TrashService.name, () => {
describe('restore', () => { describe('restore', () => {
it('should handle an empty trash', async () => { it('should handle an empty trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(0));
mocks.trash.restore.mockResolvedValue(0);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 }); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 });
expect(mocks.trash.restore).toHaveBeenCalledWith('user-id');
}); });
it('should restore', async () => { it('should restore', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(1));
mocks.trash.restore.mockResolvedValue(1); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.trash.restoreAll.mockResolvedValue(1);
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 }); await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 1 });
expect(mocks.trash.restore).toHaveBeenCalledWith('user-id');
}); });
}); });
describe('empty', () => { describe('empty', () => {
it('should handle an empty trash', async () => { it('should handle an empty trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0)); mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(0));
mocks.trash.empty.mockResolvedValue(0); mocks.trash.empty.mockResolvedValue(0);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 }); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 });
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
}); });
it('should empty the trash', async () => { it('should empty the trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1)); mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(1));
mocks.trash.empty.mockResolvedValue(1); mocks.trash.empty.mockResolvedValue(1);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 }); await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
expect(mocks.trash.empty).toHaveBeenCalledWith('user-id'); expect(mocks.trash.empty).toHaveBeenCalledWith('user-id');

View File

@@ -25,11 +25,22 @@ export class TrashService extends BaseService {
} }
async restore(auth: AuthDto): Promise<TrashResponseDto> { async restore(auth: AuthDto): Promise<TrashResponseDto> {
const count = await this.trashRepository.restore(auth.user.id); const assets = this.trashRepository.getTrashedIds(auth.user.id);
if (count > 0) { let total = 0;
this.logger.log(`Restored ${count} asset(s) from trash`); let batch = new BulkIdsDto();
batch.ids = [];
for await (const { id } of assets) {
batch.ids.push(id);
if (batch.ids.length === JOBS_ASSET_PAGINATION_SIZE) {
const { count } = await this.restoreAssets(auth, batch);
total += count;
batch = new BulkIdsDto();
batch.ids = [];
} }
return { count }; }
const { count } = await this.restoreAssets(auth, batch);
total += count;
return { count: total };
} }
async empty(auth: AuthDto): Promise<TrashResponseDto> { async empty(auth: AuthDto): Promise<TrashResponseDto> {

View File

@@ -33,6 +33,7 @@ export const newPersonRepositoryMock = (): Mocked<RepositoryInterface<PersonRepo
createAssetFace: vitest.fn(), createAssetFace: vitest.fn(),
deleteAssetFace: vitest.fn(), deleteAssetFace: vitest.fn(),
softDeleteAssetFaces: vitest.fn(), softDeleteAssetFaces: vitest.fn(),
getAssetPersonByFaceId: vitest.fn(),
vacuum: vitest.fn(), vacuum: vitest.fn(),
}; };
}; };

View File

@@ -8,9 +8,9 @@
import { AssetAction, ProjectionType } from '$lib/constants'; import { AssetAction, ProjectionType } from '$lib/constants';
import { activityManager } from '$lib/managers/activity-manager.svelte'; import { activityManager } from '$lib/managers/activity-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte'; import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { closeEditorCofirm } from '$lib/stores/asset-editor.store'; import { closeEditorCofirm } from '$lib/stores/asset-editor.store';
import { assetViewingStore } from '$lib/stores/asset-viewing.store'; import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import type { TimelineAsset } from '$lib/managers/timeline-manager/types';
import { isShowDetail } from '$lib/stores/preferences.store'; import { isShowDetail } from '$lib/stores/preferences.store';
import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store'; import { SlideshowNavigation, SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
@@ -23,6 +23,7 @@
AssetJobName, AssetJobName,
AssetTypeEnum, AssetTypeEnum,
getAllAlbums, getAllAlbums,
getAssetInfo,
getStack, getStack,
runAssetJobs, runAssetJobs,
type AlbumResponseDto, type AlbumResponseDto,
@@ -138,16 +139,20 @@
} }
}; };
const onAssetUpdate = ({ asset: assetUpdate }: { event: 'upload' | 'update'; asset: AssetResponseDto }) => { const onAssetUpdate = async (assetId: string) => {
if (assetUpdate.id === asset.id) { if (assetId === asset.id) {
asset = assetUpdate; asset = await getAssetInfo({ id: assetId, key: authManager.key });
} }
}; };
onMount(async () => { onMount(async () => {
unsubscribes.push( unsubscribes.push(
websocketEvents.on('on_upload_success', (asset) => onAssetUpdate({ event: 'upload', asset })), websocketEvents.on('on_upload_success', (asset) => onAssetUpdate(asset.id)),
websocketEvents.on('on_asset_update', (asset) => onAssetUpdate({ event: 'update', asset })), websocketEvents.on('on_asset_update', async (assetsIds) => {
for (const assetId of assetsIds) {
await onAssetUpdate(assetId);
}
}),
); );
slideshowStateUnsubscribe = slideshowState.subscribe((value) => { slideshowStateUnsubscribe = slideshowState.subscribe((value) => {

View File

@@ -1,18 +1,21 @@
<script lang="ts"> <script lang="ts">
import { shortcut } from '$lib/actions/shortcut'; import { shortcut } from '$lib/actions/shortcut';
import { authManager } from '$lib/managers/auth-manager.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte'; import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store'; import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { type AssetResponseDto } from '@immich/sdk'; import { getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import { IconButton } from '@immich/ui'; import { IconButton } from '@immich/ui';
import { mdiClose } from '@mdi/js'; import { mdiClose } from '@mdi/js';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
onMount(() => { onMount(() => {
return websocketEvents.on('on_asset_update', (assetUpdate) => { return websocketEvents.on('on_asset_update', async (assetIds) => {
if (assetUpdate.id === asset.id) { for (const assetId of assetIds) {
asset = assetUpdate; if (assetId === asset.id) {
asset = await getAssetInfo({ id: assetId, key: authManager.key });
}
} }
}); });
}); });

View File

@@ -1,4 +1,5 @@
import { user } from '$lib/stores/user.store'; import { user } from '$lib/stores/user.store';
import { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils'; import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error'; import { handleError } from '$lib/utils/handle-error';
import { import {
@@ -12,6 +13,7 @@ import {
type ActivityResponseDto, type ActivityResponseDto,
} from '@immich/sdk'; } from '@immich/sdk';
import { t } from 'svelte-i18n'; import { t } from 'svelte-i18n';
import { createSubscriber } from 'svelte/reactivity';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
type CacheKey = string; type CacheKey = string;
@@ -30,27 +32,48 @@ class ActivityManager {
#likeCount = $state(0); #likeCount = $state(0);
#isLiked = $state<ActivityResponseDto | null>(null); #isLiked = $state<ActivityResponseDto | null>(null);
#cache = new Map<CacheKey, ActivityCache>(); #subscribe;
#cache = new Map<CacheKey, ActivityCache>();
isLoading = $state(false); isLoading = $state(false);
constructor() {
this.#subscribe = createSubscriber((update) => {
const unsubscribe = websocketEvents.on('on_activity_change', ({ albumId, assetId }) => {
if (this.#albumId === albumId || this.#assetId === assetId) {
this.#invalidateCache(albumId, this.#assetId);
handlePromiseError(this.refreshActivities(albumId, this.#assetId));
update();
}
});
return () => {
unsubscribe();
};
});
}
get assetId() { get assetId() {
return this.#assetId; return this.#assetId;
} }
get activities() { get activities() {
this.#subscribe();
return this.#activities; return this.#activities;
} }
get commentCount() { get commentCount() {
this.#subscribe();
return this.#commentCount; return this.#commentCount;
} }
get likeCount() { get likeCount() {
this.#subscribe();
return this.#likeCount; return this.#likeCount;
} }
get isLiked() { get isLiked() {
this.#subscribe();
return this.#isLiked; return this.#isLiked;
} }
@@ -78,7 +101,7 @@ class ActivityManager {
} }
async addActivity(dto: ActivityCreateDto) { async addActivity(dto: ActivityCreateDto) {
if (this.#albumId === undefined) { if (!this.#albumId) {
return; return;
} }
@@ -87,9 +110,7 @@ class ActivityManager {
if (activity.type === ReactionType.Comment) { if (activity.type === ReactionType.Comment) {
this.#commentCount++; this.#commentCount++;
} } else if (activity.type === ReactionType.Like) {
if (activity.type === ReactionType.Like) {
this.#likeCount++; this.#likeCount++;
} }
@@ -105,15 +126,15 @@ class ActivityManager {
if (activity.type === ReactionType.Comment) { if (activity.type === ReactionType.Comment) {
this.#commentCount--; this.#commentCount--;
} } else if (activity.type === ReactionType.Like) {
if (activity.type === ReactionType.Like) {
this.#likeCount--; this.#likeCount--;
} }
this.#activities = index if (index === undefined) {
? this.#activities.splice(index, 1) this.#activities = this.#activities.filter(({ id }) => id !== activity.id);
: this.#activities.filter(({ id }) => id !== activity.id); } else {
this.#activities.splice(index, 1);
}
await deleteActivity({ id: activity.id }); await deleteActivity({ id: activity.id });
this.#invalidateCache(this.#albumId, this.#assetId); this.#invalidateCache(this.#albumId, this.#assetId);
@@ -128,12 +149,17 @@ class ActivityManager {
if (this.#isLiked) { if (this.#isLiked) {
await this.deleteActivity(this.#isLiked); await this.deleteActivity(this.#isLiked);
this.#isLiked = null; this.#isLiked = null;
} else { return;
this.#isLiked = (await this.addActivity({ }
const newLike = await this.addActivity({
albumId: this.#albumId, albumId: this.#albumId,
assetId: this.#assetId, assetId: this.#assetId,
type: ReactionType.Like, type: ReactionType.Like,
}))!; });
if (newLike) {
this.#isLiked = newLike;
} }
} }

View File

@@ -13,7 +13,8 @@ export function updateObject(target: any, source: any): boolean {
} }
const isDate = target[key] instanceof Date; const isDate = target[key] instanceof Date;
if (typeof target[key] === 'object' && !isDate) { if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]); const updatedChild = updateObject(target[key], source[key]);
updated = updated || updatedChild;
} else { } else {
if (target[key] !== source[key]) { if (target[key] !== source[key]) {
target[key] = source[key]; target[key] = source[key];

View File

@@ -1,85 +1,315 @@
import { authManager } from '$lib/managers/auth-manager.svelte';
import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte'; import type { TimelineManager } from '$lib/managers/timeline-manager/timeline-manager.svelte';
import type { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket'; import { websocketEvents } from '$lib/stores/websocket';
import { toTimelineAsset } from '$lib/utils/timeline-util'; import { toTimelineAsset } from '$lib/utils/timeline-util';
import { throttle } from 'lodash-es'; import { getAllAlbums, getAssetInfo, type AssetResponseDto } from '@immich/sdk';
import type { Unsubscriber } from 'svelte/store'; import type { Unsubscriber } from 'svelte/store';
const PROCESS_DELAY_MS = 2500;
const fetchAssetInfos = async (assetIds: string[]) => {
return await Promise.all(assetIds.map((id) => getAssetInfo({ id, key: authManager.key })));
};
export type AssetFilter = (
asset: Awaited<ReturnType<typeof getAssetInfo>>,
timelineManager: TimelineManager,
) => Promise<boolean> | boolean;
// Filter functions
const checkVisibilityProperty: AssetFilter = (asset, timelineManager) => {
if (timelineManager.options.visibility === undefined) {
return true;
}
const timelineAsset = toTimelineAsset(asset);
return timelineManager.options.visibility === timelineAsset.visibility;
};
const checkFavoriteProperty: AssetFilter = (asset, timelineManager) => {
if (timelineManager.options.isFavorite === undefined) {
return true;
}
const timelineAsset = toTimelineAsset(asset);
return timelineManager.options.isFavorite === timelineAsset.isFavorite;
};
const checkTrashedProperty: AssetFilter = (asset, timelineManager) => {
if (timelineManager.options.isTrashed === undefined) {
return true;
}
const timelineAsset = toTimelineAsset(asset);
return timelineManager.options.isTrashed === timelineAsset.isTrashed;
};
const checkTagProperty: AssetFilter = (asset, timelineManager) => {
if (!timelineManager.options.tagId) {
return true;
}
return asset.tags?.some((tag: { id: string }) => tag.id === timelineManager.options.tagId) ?? false;
};
const checkAlbumProperty: AssetFilter = async (asset, timelineManager) => {
if (!timelineManager.options.albumId) {
return true;
}
const albums = await getAllAlbums({ assetId: asset.id });
return albums.some((album) => album.id === timelineManager.options.albumId);
};
const checkPersonProperty: AssetFilter = (asset, timelineManager) => {
if (!timelineManager.options.personId) {
return true;
}
return asset.people?.some((person: { id: string }) => person.id === timelineManager.options.personId) ?? false;
};
export class WebsocketSupport { export class WebsocketSupport {
#pendingChanges: PendingChange[] = []; readonly #timelineManager: TimelineManager;
#unsubscribers: Unsubscriber[] = []; #unsubscribers: Unsubscriber[] = [];
#timelineManager: TimelineManager;
#processPendingChanges = throttle(() => { #pendingUpdates: {
const { add, update, remove } = this.#getPendingChangeBatches(); updated: string[];
if (add.length > 0) { trashed: string[];
this.#timelineManager.addAssets(add); restored: string[];
deleted: string[];
personed: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[];
album: { albumId: string; assetId: string[]; status: 'added' | 'removed' }[];
};
/**
* Count of pending updates across all categories.
* This is used to determine if there are any updates to process.
*/
#pendingCount() {
return (
this.#pendingUpdates.updated.length +
this.#pendingUpdates.trashed.length +
this.#pendingUpdates.restored.length +
this.#pendingUpdates.deleted.length +
this.#pendingUpdates.personed.length +
this.#pendingUpdates.album.length
);
} }
if (update.length > 0) { #processTimeoutId: ReturnType<typeof setTimeout> | undefined;
this.#timelineManager.updateAssets(update); #isProcessing = false;
}
if (remove.length > 0) {
this.#timelineManager.removeAssets(remove);
}
this.#pendingChanges = [];
}, 2500);
constructor(timeineManager: TimelineManager) { constructor(timelineManager: TimelineManager) {
this.#timelineManager = timeineManager; this.#pendingUpdates = this.#init();
this.#timelineManager = timelineManager;
}
#init() {
return {
updated: [],
trashed: [],
restored: [],
deleted: [],
personed: [],
album: [],
};
} }
connectWebsocketEvents() { connectWebsocketEvents() {
this.#unsubscribers.push( this.#unsubscribers.push(
websocketEvents.on('on_upload_success', (asset) => websocketEvents.on('on_asset_trash', (ids) => {
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }), this.#pendingUpdates.trashed.push(...ids);
), this.#scheduleProcessing();
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })), }),
websocketEvents.on('on_asset_update', (asset) => // this event is called when a person is added or removed from an asset
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }), websocketEvents.on('on_asset_person', (data) => {
), this.#pendingUpdates.personed.push(data);
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })), this.#scheduleProcessing();
}),
// uploads and tagging are handled by this event
websocketEvents.on('on_asset_update', (ids) => {
this.#pendingUpdates.updated.push(...ids);
this.#scheduleProcessing();
}),
// this event is called when an asset is added or removed from an album
websocketEvents.on('on_album_update', (data) => {
this.#pendingUpdates.album.push(data);
this.#scheduleProcessing();
}),
websocketEvents.on('on_asset_delete', (ids) => {
this.#pendingUpdates.deleted.push(ids);
this.#scheduleProcessing();
}),
websocketEvents.on('on_asset_restore', (ids) => {
this.#pendingUpdates.restored.push(...ids);
this.#scheduleProcessing();
}),
); );
} }
disconnectWebsocketEvents() { disconnectWebsocketEvents() {
this.#cleanup();
}
#cleanup() {
for (const unsubscribe of this.#unsubscribers) { for (const unsubscribe of this.#unsubscribers) {
unsubscribe(); unsubscribe();
} }
this.#unsubscribers = []; this.#unsubscribers = [];
this.#cancelScheduledProcessing();
} }
#addPendingChanges(...changes: PendingChange[]) { #cancelScheduledProcessing() {
this.#pendingChanges.push(...changes); if (this.#processTimeoutId) {
this.#processPendingChanges(); clearTimeout(this.#processTimeoutId);
this.#processTimeoutId = undefined;
}
} }
#getPendingChangeBatches() { #scheduleProcessing() {
const batch: { if (this.#processTimeoutId) {
add: TimelineAsset[]; return;
update: TimelineAsset[];
remove: string[];
} = {
add: [],
update: [],
remove: [],
};
for (const { type, values } of this.#pendingChanges) {
switch (type) {
case 'add': {
batch.add.push(...values);
break;
} }
case 'update': {
batch.update.push(...values); this.#processTimeoutId = setTimeout(() => {
break; this.#processTimeoutId = undefined;
void this.#applyPendingChanges();
}, PROCESS_DELAY_MS);
} }
case 'delete':
case 'trash': { async #applyPendingChanges() {
batch.remove.push(...values); if (this.#isProcessing || this.#pendingCount() === 0) {
break; return;
}
this.#isProcessing = true;
try {
await this.#processAllPendingUpdates();
} finally {
this.#isProcessing = false;
if (this.#pendingCount() > 0) {
this.#scheduleProcessing();
} }
} }
} }
return batch;
async #processAllPendingUpdates() {
const pendingUpdates = this.#pendingUpdates;
this.#pendingUpdates = this.#init();
await this.#filterAndUpdateAssets(
[...pendingUpdates.updated, ...pendingUpdates.trashed, ...pendingUpdates.restored],
[checkVisibilityProperty, checkFavoriteProperty, checkTrashedProperty, checkTagProperty, checkAlbumProperty],
);
await this.#handlePersonUpdates(pendingUpdates.personed);
await this.#handleAlbumUpdates(pendingUpdates.album);
this.#timelineManager.removeAssets(pendingUpdates.deleted);
}
async #filterAndUpdateAssets(assetIds: string[], filters: AssetFilter[]) {
if (assetIds.length === 0) {
return;
}
const assets = await fetchAssetInfos(assetIds);
const assetsToAdd = [];
const assetsToRemove = [];
for (const asset of assets) {
if (await this.#shouldAssetBeIncluded(asset, filters)) {
assetsToAdd.push(asset);
} else {
assetsToRemove.push(asset.id);
}
}
this.#timelineManager.addAssets(assetsToAdd.map((asset) => toTimelineAsset(asset)));
this.#timelineManager.removeAssets(assetsToRemove);
}
async #shouldAssetBeIncluded(asset: AssetResponseDto, filters: AssetFilter[]): Promise<boolean> {
for (const filter of filters) {
const result = await filter(asset, this.#timelineManager);
if (!result) {
return false;
}
}
return true;
}
async #handlePersonUpdates(
data: { assetId: string; personId: string | undefined; status: 'created' | 'removed' | 'removed_soft' }[],
) {
if (data.length === 0) {
return;
}
const assetsToRemove: string[] = [];
const personAssetsToAdd: string[] = [];
const targetPersonId = this.#timelineManager.options.personId;
if (targetPersonId === undefined) {
// If no person filter, add all assets with person changes
personAssetsToAdd.push(...data.map((d) => d.assetId));
} else {
for (const { assetId, personId, status } of data) {
if (status === 'created' && personId === targetPersonId) {
personAssetsToAdd.push(assetId);
} else if ((status === 'removed' || status === 'removed_soft') && personId === targetPersonId) {
assetsToRemove.push(assetId);
}
}
}
this.#timelineManager.removeAssets(assetsToRemove);
// Filter and add assets that now have the target person
await this.#filterAndUpdateAssets(personAssetsToAdd, [
checkVisibilityProperty,
checkFavoriteProperty,
checkTrashedProperty,
checkTagProperty,
checkAlbumProperty,
]);
}
async #handleAlbumUpdates(data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }[]) {
if (data.length === 0) {
return;
}
const assetsToAdd: string[] = [];
const assetsToRemove: string[] = [];
const targetAlbumId = this.#timelineManager.options.albumId;
if (targetAlbumId === undefined) {
// If no album filter, add all assets with album changes
assetsToAdd.push(...data.flatMap((d) => d.assetId));
} else {
for (const { albumId, assetId, status } of data) {
if (albumId !== targetAlbumId) {
continue;
}
if (status === 'added') {
assetsToAdd.push(...assetId);
} else if (status === 'removed') {
assetsToRemove.push(...assetId);
}
}
}
this.#timelineManager.removeAssets(assetsToRemove);
// Filter and add assets that are now in the target album
await this.#filterAndUpdateAssets(assetsToAdd, [
checkVisibilityProperty,
checkFavoriteProperty,
checkTrashedProperty,
checkTagProperty,
checkPersonProperty,
]);
} }
} }

View File

@@ -59,9 +59,6 @@ export class TimelineManager {
initTask = new CancellableTask( initTask = new CancellableTask(
() => { () => {
this.isInitialized = true; this.isInitialized = true;
if (this.#options.albumId || this.#options.personId) {
return;
}
this.connect(); this.connect();
}, },
() => { () => {
@@ -189,6 +186,10 @@ export class TimelineManager {
return this.#viewportHeight; return this.#viewportHeight;
} }
get options() {
return { ...this.#options };
}
async *assetsIterator(options?: { async *assetsIterator(options?: {
startMonthGroup?: MonthGroup; startMonthGroup?: MonthGroup;
startDayGroup?: DayGroup; startDayGroup?: DayGroup;
@@ -410,6 +411,9 @@ export class TimelineManager {
} }
addAssets(assets: TimelineAsset[]) { addAssets(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset)); const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.updateAssets(assetsToUpdate); const notUpdated = this.updateAssets(assetsToUpdate);
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc }); addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
@@ -478,6 +482,9 @@ export class TimelineManager {
} }
removeAssets(ids: string[]) { removeAssets(ids: string[]) {
if (ids.length === 0) {
return [];
}
const { unprocessedIds } = runAssetOperation( const { unprocessedIds } = runAssetOperation(
this, this,
new Set(ids), new Set(ids),

View File

@@ -16,9 +16,19 @@ export interface ReleaseEvent {
export interface Events { export interface Events {
on_upload_success: (asset: AssetResponseDto) => void; on_upload_success: (asset: AssetResponseDto) => void;
on_user_delete: (id: string) => void; on_user_delete: (id: string) => void;
on_activity_change: (data: { albumId: string; assetId: string | null }) => void;
on_album_update: (data: { albumId: string; assetId: string[]; status: 'added' | 'removed' }) => void;
on_asset_person: ({
assetId,
personId,
}: {
assetId: string;
personId: string | undefined;
status: 'created' | 'removed' | 'removed_soft';
}) => void;
on_asset_delete: (assetId: string) => void; on_asset_delete: (assetId: string) => void;
on_asset_trash: (assetIds: string[]) => void; on_asset_trash: (assetIds: string[]) => void;
on_asset_update: (asset: AssetResponseDto) => void; on_asset_update: (assetIds: string[]) => void;
on_asset_hidden: (assetId: string) => void; on_asset_hidden: (assetId: string) => void;
on_asset_restore: (assetIds: string[]) => void; on_asset_restore: (assetIds: string[]) => void;
on_asset_stack_update: (assetIds: string[]) => void; on_asset_stack_update: (assetIds: string[]) => void;