mirror of
https://github.com/immich-app/immich.git
synced 2025-12-16 17:23:16 +03:00
Compare commits
12 Commits
gha-eps-23
...
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)
|
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);
|
||||||
|
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
|||||||
@@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' })
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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(),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user