mirror of
https://github.com/immich-app/immich.git
synced 2025-12-05 20:40:29 -08:00
Compare commits
12 Commits
77578947f8
...
timeline_e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
053dd490b4 | ||
|
|
f4f81341da | ||
|
|
3e66913cf8 | ||
|
|
504309eff5 | ||
|
|
b44abf5b4b | ||
|
|
c76e8da173 | ||
|
|
9cc2189ef7 | ||
|
|
6b87efe7a3 | ||
|
|
7b75da1f10 | ||
|
|
a7559f0691 | ||
|
|
6f2f295cf3 | ||
|
|
b3d080f6e8 |
@@ -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);
|
||||
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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) };
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user