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)
.get('/timeline/bucket')
.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);

View File

@@ -216,7 +216,11 @@ export const utils = {
websocket
.on('connect', () => resolve(websocket))
.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_delete', (assetId: string) => onEvent({ event: 'assetDelete', id: assetId }))
.on('on_user_delete', (userId: string) => onEvent({ event: 'userDelete', id: userId }))

View File

@@ -279,6 +279,15 @@ where
"asset_faces"."personId" = $1
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
select
max("asset_job_status"."facesRecognizedAt")::text as "latestDate"

View File

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

View File

@@ -47,11 +47,20 @@ type EventMap = {
];
'config.validate': [{ newConfig: SystemConfig; oldConfig: SystemConfig }];
// activity events
'activity.change': [{ recipientId: string[]; userId: string; albumId: string; assetId: string | null }];
// 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 }];
// 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.untag': [{ assetId: string }];
'asset.hide': [{ assetId: string; userId: string }];
@@ -97,9 +106,12 @@ export type ArgsOf<T extends EmitEvent> = EventMap[T];
export interface ClientEventMap {
on_upload_success: [AssetResponseDto];
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_trash: [string[]];
on_asset_update: [AssetResponseDto];
on_asset_update: [string[]];
on_asset_hidden: [string];
on_asset_restore: [string[]];
on_asset_stack_update: string[];

View File

@@ -483,6 +483,15 @@ export class PersonRepository {
.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()
async getLatestFaceDate(): Promise<string | undefined> {
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();
}
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] })
async restore(userId: string): Promise<number> {
const { numUpdatedRows } = await this.db

View File

@@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto';
import { ActivityService } from 'src/services/activity.service';
import { albumStub } from 'test/fixtures/album.stub';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -79,6 +80,11 @@ describe(ActivityService.name, () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
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 } }), {
albumId,
@@ -115,6 +121,11 @@ describe(ActivityService.name, () => {
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity);
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 });

View File

@@ -1,4 +1,4 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { Activity } from 'src/database';
import {
ActivityCreateDto,
@@ -58,11 +58,24 @@ export class ActivityService extends BaseService {
}
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({
...common,
isLiked: dto.type === ReactionType.LIKE,
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) };

View File

@@ -664,7 +664,10 @@ describe(AlbumService.name, () => {
expect(mocks.album.addAssetIds).toHaveBeenCalledWith('album-123', ['asset-1', 'asset-2', 'asset-3']);
expect(mocks.event.emit).toHaveBeenCalledWith('album.update', {
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,
);
for (const recipientId of allUsersExceptUs) {
await this.eventRepository.emit('album.update', { id, recipientId });
}
await this.eventRepository.emit('album.update', {
id,
userId: auth.user.id,
assetId: dto.ids,
recipientId: allUsersExceptUs,
status: 'added',
});
}
return results;
@@ -200,7 +204,16 @@ export class AlbumService extends BaseService {
if (removedIds.length > 0 && album.albumThumbnailAssetId && removedIds.includes(album.albumThumbnailAssetId)) {
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;
}

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) {
await onAfterUnlink(repos, {
@@ -113,35 +130,27 @@ export class AssetService extends BaseService {
}
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 });
if (
description !== undefined ||
dateTimeOriginal !== undefined ||
latitude !== undefined ||
longitude !== undefined
) {
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 },
})),
);
}
const metadataUpdated = await this.updateAllMetadata(ids, {
description,
dateTimeOriginal,
latitude,
longitude,
rating,
});
if (
options.visibility !== undefined ||
options.isFavorite !== undefined ||
options.duplicateId !== undefined ||
options.rating !== undefined
) {
await this.assetRepository.updateAll(ids, options);
if (rest.visibility !== undefined || rest.isFavorite !== undefined || rest.duplicateId !== undefined) {
await this.assetRepository.updateAll(ids, rest);
if (options.visibility === AssetVisibility.LOCKED) {
if (rest.visibility === AssetVisibility.LOCKED) {
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) {
await this.assetRepository.upsertExif({ assetId: 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 { AssetFileType, JobName, JobStatus, UserMetadataKey } from 'src/enum';
import { NotificationService } from 'src/services/notification.service';
import { INotifyAlbumUpdateJob } from 'src/types';
import { albumStub } from 'test/fixtures/album.stub';
import { assetStub } from 'test/fixtures/asset.stub';
import { userStub } from 'test/fixtures/user.stub';
@@ -154,7 +153,7 @@ describe(NotificationService.name, () => {
describe('onAlbumUpdateEvent', () => {
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({
name: JobName.NOTIFY_ALBUM_UPDATE,
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 () => {
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.queue).toHaveBeenCalledWith({
name: JobName.NOTIFY_ALBUM_UPDATE,

View File

@@ -1,6 +1,5 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { OnEvent, OnJob } from 'src/decorators';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import {
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' })
onAssetHide({ assetId, userId }: ArgOf<'asset.hide'>) {
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);
}
@OnEvent({ name: 'asset.update' })
onAssetUpdate({ assetIds, userId }: ArgOf<'asset.update'>) {
this.eventRepository.clientSend('on_asset_update', userId, assetIds);
}
@OnEvent({ name: 'asset.metadataExtracted' })
async onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) {
onAssetMetadataExtracted({ assetId, userId, source }: ArgOf<'asset.metadataExtracted'>) {
if (source !== 'sidecar-write') {
return;
}
const [asset] = await this.assetRepository.getByIdsWithAllRelationsButStacks([assetId]);
if (asset) {
this.eventRepository.clientSend('on_asset_update', userId, mapAsset(asset));
}
this.eventRepository.clientSend('on_asset_update', userId, [assetId]);
}
@OnEvent({ name: 'assets.restore' })
@@ -198,12 +212,23 @@ export class NotificationService extends BaseService {
}
@OnEvent({ name: 'album.update' })
async onAlbumUpdate({ id, recipientId }: ArgOf<'album.update'>) {
await this.jobRepository.removeJob(JobName.NOTIFY_ALBUM_UPDATE, `${id}/${recipientId}`);
await this.jobRepository.queue({
name: JobName.NOTIFY_ALBUM_UPDATE,
data: { id, recipientId, delay: NotificationService.albumUpdateEmailDelayMs },
});
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.queue({
name: JobName.NOTIFY_ALBUM_UPDATE,
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' })

View File

@@ -627,11 +627,28 @@ export class PersonService extends BaseService {
boundingBoxY2: dto.y + dto.height,
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> {
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', () => {
it('should handle an empty trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0));
mocks.trash.restore.mockResolvedValue(0);
mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(0));
await expect(sut.restore(authStub.user1)).resolves.toEqual({ count: 0 });
expect(mocks.trash.restore).toHaveBeenCalledWith('user-id');
});
it('should restore', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1));
mocks.trash.restore.mockResolvedValue(1);
mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(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 });
expect(mocks.trash.restore).toHaveBeenCalledWith('user-id');
});
});
describe('empty', () => {
it('should handle an empty trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(0));
mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(0));
mocks.trash.empty.mockResolvedValue(0);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 0 });
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should empty the trash', async () => {
mocks.trash.getDeletedIds.mockResolvedValue(makeAssetIdStream(1));
mocks.trash.getTrashedIds.mockReturnValue(makeAssetIdStream(1));
mocks.trash.empty.mockResolvedValue(1);
await expect(sut.empty(authStub.user1)).resolves.toEqual({ count: 1 });
expect(mocks.trash.empty).toHaveBeenCalledWith('user-id');

View File

@@ -25,11 +25,22 @@ export class TrashService extends BaseService {
}
async restore(auth: AuthDto): Promise<TrashResponseDto> {
const count = await this.trashRepository.restore(auth.user.id);
if (count > 0) {
this.logger.log(`Restored ${count} asset(s) from trash`);
const assets = this.trashRepository.getTrashedIds(auth.user.id);
let total = 0;
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> {

View File

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

View File

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

View File

@@ -1,18 +1,21 @@
<script lang="ts">
import { shortcut } from '$lib/actions/shortcut';
import { authManager } from '$lib/managers/auth-manager.svelte';
import ConfirmModal from '$lib/modals/ConfirmModal.svelte';
import { editTypes, showCancelConfirmDialog } from '$lib/stores/asset-editor.store';
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 { mdiClose } from '@mdi/js';
import { onMount } from 'svelte';
import { t } from 'svelte-i18n';
onMount(() => {
return websocketEvents.on('on_asset_update', (assetUpdate) => {
if (assetUpdate.id === asset.id) {
asset = assetUpdate;
return websocketEvents.on('on_asset_update', async (assetIds) => {
for (const assetId of assetIds) {
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 { websocketEvents } from '$lib/stores/websocket';
import { handlePromiseError } from '$lib/utils';
import { handleError } from '$lib/utils/handle-error';
import {
@@ -12,6 +13,7 @@ import {
type ActivityResponseDto,
} from '@immich/sdk';
import { t } from 'svelte-i18n';
import { createSubscriber } from 'svelte/reactivity';
import { get } from 'svelte/store';
type CacheKey = string;
@@ -30,27 +32,48 @@ class ActivityManager {
#likeCount = $state(0);
#isLiked = $state<ActivityResponseDto | null>(null);
#cache = new Map<CacheKey, ActivityCache>();
#subscribe;
#cache = new Map<CacheKey, ActivityCache>();
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() {
return this.#assetId;
}
get activities() {
this.#subscribe();
return this.#activities;
}
get commentCount() {
this.#subscribe();
return this.#commentCount;
}
get likeCount() {
this.#subscribe();
return this.#likeCount;
}
get isLiked() {
this.#subscribe();
return this.#isLiked;
}
@@ -78,7 +101,7 @@ class ActivityManager {
}
async addActivity(dto: ActivityCreateDto) {
if (this.#albumId === undefined) {
if (!this.#albumId) {
return;
}
@@ -87,9 +110,7 @@ class ActivityManager {
if (activity.type === ReactionType.Comment) {
this.#commentCount++;
}
if (activity.type === ReactionType.Like) {
} else if (activity.type === ReactionType.Like) {
this.#likeCount++;
}
@@ -105,15 +126,15 @@ class ActivityManager {
if (activity.type === ReactionType.Comment) {
this.#commentCount--;
}
if (activity.type === ReactionType.Like) {
} else if (activity.type === ReactionType.Like) {
this.#likeCount--;
}
this.#activities = index
? this.#activities.splice(index, 1)
: this.#activities.filter(({ id }) => id !== activity.id);
if (index === undefined) {
this.#activities = this.#activities.filter(({ id }) => id !== activity.id);
} else {
this.#activities.splice(index, 1);
}
await deleteActivity({ id: activity.id });
this.#invalidateCache(this.#albumId, this.#assetId);
@@ -128,12 +149,17 @@ class ActivityManager {
if (this.#isLiked) {
await this.deleteActivity(this.#isLiked);
this.#isLiked = null;
} else {
this.#isLiked = (await this.addActivity({
albumId: this.#albumId,
assetId: this.#assetId,
type: ReactionType.Like,
}))!;
return;
}
const newLike = await this.addActivity({
albumId: this.#albumId,
assetId: this.#assetId,
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;
if (typeof target[key] === 'object' && !isDate) {
updated = updated || updateObject(target[key], source[key]);
const updatedChild = updateObject(target[key], source[key]);
updated = updated || updatedChild;
} else {
if (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 { PendingChange, TimelineAsset } from '$lib/managers/timeline-manager/types';
import { websocketEvents } from '$lib/stores/websocket';
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';
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 {
#pendingChanges: PendingChange[] = [];
readonly #timelineManager: TimelineManager;
#unsubscribers: Unsubscriber[] = [];
#timelineManager: TimelineManager;
#processPendingChanges = throttle(() => {
const { add, update, remove } = this.#getPendingChangeBatches();
if (add.length > 0) {
this.#timelineManager.addAssets(add);
}
if (update.length > 0) {
this.#timelineManager.updateAssets(update);
}
if (remove.length > 0) {
this.#timelineManager.removeAssets(remove);
}
this.#pendingChanges = [];
}, 2500);
#pendingUpdates: {
updated: string[];
trashed: string[];
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
);
}
#processTimeoutId: ReturnType<typeof setTimeout> | undefined;
#isProcessing = false;
constructor(timeineManager: TimelineManager) {
this.#timelineManager = timeineManager;
constructor(timelineManager: TimelineManager) {
this.#pendingUpdates = this.#init();
this.#timelineManager = timelineManager;
}
#init() {
return {
updated: [],
trashed: [],
restored: [],
deleted: [],
personed: [],
album: [],
};
}
connectWebsocketEvents() {
this.#unsubscribers.push(
websocketEvents.on('on_upload_success', (asset) =>
this.#addPendingChanges({ type: 'add', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_trash', (ids) => this.#addPendingChanges({ type: 'trash', values: ids })),
websocketEvents.on('on_asset_update', (asset) =>
this.#addPendingChanges({ type: 'update', values: [toTimelineAsset(asset)] }),
),
websocketEvents.on('on_asset_delete', (id: string) => this.#addPendingChanges({ type: 'delete', values: [id] })),
websocketEvents.on('on_asset_trash', (ids) => {
this.#pendingUpdates.trashed.push(...ids);
this.#scheduleProcessing();
}),
// this event is called when a person is added or removed from an asset
websocketEvents.on('on_asset_person', (data) => {
this.#pendingUpdates.personed.push(data);
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() {
this.#cleanup();
}
#cleanup() {
for (const unsubscribe of this.#unsubscribers) {
unsubscribe();
}
this.#unsubscribers = [];
this.#cancelScheduledProcessing();
}
#addPendingChanges(...changes: PendingChange[]) {
this.#pendingChanges.push(...changes);
this.#processPendingChanges();
#cancelScheduledProcessing() {
if (this.#processTimeoutId) {
clearTimeout(this.#processTimeoutId);
this.#processTimeoutId = undefined;
}
}
#getPendingChangeBatches() {
const batch: {
add: TimelineAsset[];
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);
break;
}
case 'delete':
case 'trash': {
batch.remove.push(...values);
break;
#scheduleProcessing() {
if (this.#processTimeoutId) {
return;
}
this.#processTimeoutId = setTimeout(() => {
this.#processTimeoutId = undefined;
void this.#applyPendingChanges();
}, PROCESS_DELAY_MS);
}
async #applyPendingChanges() {
if (this.#isProcessing || this.#pendingCount() === 0) {
return;
}
this.#isProcessing = true;
try {
await this.#processAllPendingUpdates();
} finally {
this.#isProcessing = false;
if (this.#pendingCount() > 0) {
this.#scheduleProcessing();
}
}
}
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);
}
}
}
return batch;
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(
() => {
this.isInitialized = true;
if (this.#options.albumId || this.#options.personId) {
return;
}
this.connect();
},
() => {
@@ -189,6 +186,10 @@ export class TimelineManager {
return this.#viewportHeight;
}
get options() {
return { ...this.#options };
}
async *assetsIterator(options?: {
startMonthGroup?: MonthGroup;
startDayGroup?: DayGroup;
@@ -410,6 +411,9 @@ export class TimelineManager {
}
addAssets(assets: TimelineAsset[]) {
if (assets.length === 0) {
return;
}
const assetsToUpdate = assets.filter((asset) => !this.isExcluded(asset));
const notUpdated = this.updateAssets(assetsToUpdate);
addAssetsToMonthGroups(this, [...notUpdated], { order: this.#options.order ?? AssetOrder.Desc });
@@ -478,6 +482,9 @@ export class TimelineManager {
}
removeAssets(ids: string[]) {
if (ids.length === 0) {
return [];
}
const { unprocessedIds } = runAssetOperation(
this,
new Set(ids),

View File

@@ -16,9 +16,19 @@ export interface ReleaseEvent {
export interface Events {
on_upload_success: (asset: AssetResponseDto) => 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_trash: (assetIds: string[]) => void;
on_asset_update: (asset: AssetResponseDto) => void;
on_asset_update: (assetIds: string[]) => void;
on_asset_hidden: (assetId: string) => void;
on_asset_restore: (assetIds: string[]) => void;
on_asset_stack_update: (assetIds: string[]) => void;