mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
refactor: event manager (#25481)
* refactor: event manager * fix: broken downloadFile endpoint
This commit is contained in:
parent
b52e8cd570
commit
4fedae4150
16 changed files with 45 additions and 116 deletions
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
|
|
@ -504,16 +504,22 @@ jobs:
|
||||||
CI: true
|
CI: true
|
||||||
run: npx playwright test --project=chromium
|
run: npx playwright test --project=chromium
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
|
- name: Archive web results
|
||||||
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
|
if: success() || failure()
|
||||||
|
with:
|
||||||
|
name: e2e-web-test-results-${{ matrix.runner }}
|
||||||
|
path: e2e/playwright-report/
|
||||||
- name: Run ui tests (web)
|
- name: Run ui tests (web)
|
||||||
env:
|
env:
|
||||||
CI: true
|
CI: true
|
||||||
run: npx playwright test --project=ui
|
run: npx playwright test --project=ui
|
||||||
if: ${{ !cancelled() }}
|
if: ${{ !cancelled() }}
|
||||||
- name: Archive test results
|
- name: Archive ui results
|
||||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
|
||||||
if: success() || failure()
|
if: success() || failure()
|
||||||
with:
|
with:
|
||||||
name: e2e-web-test-results-${{ matrix.runner }}
|
name: e2e-ui-test-results-${{ matrix.runner }}
|
||||||
path: e2e/playwright-report/
|
path: e2e/playwright-report/
|
||||||
success-check-e2e:
|
success-check-e2e:
|
||||||
name: End-to-End Tests Success
|
name: End-to-End Tests Success
|
||||||
|
|
|
||||||
|
|
@ -601,15 +601,15 @@ where
|
||||||
|
|
||||||
-- AssetRepository.getForThumbnail
|
-- AssetRepository.getForThumbnail
|
||||||
select
|
select
|
||||||
"asset_file"."path",
|
|
||||||
"asset"."originalPath",
|
"asset"."originalPath",
|
||||||
"asset"."originalFileName"
|
"asset"."originalFileName",
|
||||||
|
"asset_file"."path" as "path"
|
||||||
from
|
from
|
||||||
"asset_file"
|
"asset"
|
||||||
right join "asset" on "asset"."id" = "asset_file"."assetId"
|
left join "asset_file" on "asset"."id" = "asset_file"."assetId"
|
||||||
|
and "asset_file"."type" = $1
|
||||||
where
|
where
|
||||||
"asset_file"."assetId" = $1
|
"asset"."id" = $2
|
||||||
and "asset_file"."type" = $2
|
|
||||||
order by
|
order by
|
||||||
"asset_file"."isEdited" desc
|
"asset_file"."isEdited" desc
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1033,12 +1033,12 @@ export class AssetRepository {
|
||||||
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview] })
|
@GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview] })
|
||||||
async getForThumbnail(id: string, type: AssetFileType) {
|
async getForThumbnail(id: string, type: AssetFileType) {
|
||||||
return this.db
|
return this.db
|
||||||
.selectFrom('asset_file')
|
.selectFrom('asset')
|
||||||
.select('asset_file.path')
|
.where('asset.id', '=', id)
|
||||||
.where('asset_file.assetId', '=', id)
|
.leftJoin('asset_file', (join) =>
|
||||||
.where('asset_file.type', '=', type)
|
join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', type),
|
||||||
.rightJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_file.assetId'))
|
)
|
||||||
.select(['asset.originalPath', 'asset.originalFileName'])
|
.select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path'])
|
||||||
.orderBy('asset_file.isEdited', 'desc')
|
.orderBy('asset_file.isEdited', 'desc')
|
||||||
.executeTakeFirstOrThrow();
|
.executeTakeFirstOrThrow();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -597,15 +597,6 @@ describe(AssetMediaService.name, () => {
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw a not found when edits exist but no edited file available', async () => {
|
|
||||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
|
|
||||||
mocks.asset.getForOriginal.mockResolvedValue({ ...assetStub.withCropEdit, editedPath: null });
|
|
||||||
|
|
||||||
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).rejects.toBeInstanceOf(
|
|
||||||
NotFoundException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('viewThumbnail', () => {
|
describe('viewThumbnail', () => {
|
||||||
|
|
|
||||||
|
|
@ -201,10 +201,6 @@ export class AssetMediaService extends BaseService {
|
||||||
dto.edited ?? false,
|
dto.edited ?? false,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (dto.edited && !editedPath) {
|
|
||||||
throw new NotFoundException('Edited asset media not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = editedPath ?? originalPath!;
|
const path = editedPath ?? originalPath!;
|
||||||
|
|
||||||
return new ImmichFileResponse({
|
return new ImmichFileResponse({
|
||||||
|
|
@ -240,6 +236,10 @@ export class AssetMediaService extends BaseService {
|
||||||
return { targetSize: AssetMediaSize.PREVIEW };
|
return { targetSize: AssetMediaSize.PREVIEW };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
throw new NotFoundException('Asset media not found');
|
||||||
|
}
|
||||||
|
|
||||||
const fileName = `${getFileNameWithoutExtension(originalFileName)}_${size}${getFilenameExtension(path)}`;
|
const fileName = `${getFileNameWithoutExtension(originalFileName)}_${size}${getFilenameExtension(path)}`;
|
||||||
|
|
||||||
return new ImmichFileResponse({
|
return new ImmichFileResponse({
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,10 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const props: Props = $props();
|
const props: Props = $props();
|
||||||
const unsubscribes: Array<() => void> = [];
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const unsubscribes: Array<() => void> = [];
|
||||||
|
|
||||||
for (const name of Object.keys(props)) {
|
for (const name of Object.keys(props)) {
|
||||||
const event = name.slice(2) as keyof Events;
|
const event = name.slice(2) as keyof Events;
|
||||||
const listener = props[name as keyof Props] as EventCallback<Events, typeof event> | undefined;
|
const listener = props[name as keyof Props] as EventCallback<Events, typeof event> | undefined;
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,10 @@
|
||||||
};
|
};
|
||||||
|
|
||||||
const props: Props = $props();
|
const props: Props = $props();
|
||||||
const unsubscribes: Array<() => void> = [];
|
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
|
const unsubscribes: Array<() => void> = [];
|
||||||
|
|
||||||
for (const name of Object.keys(props)) {
|
for (const name of Object.keys(props)) {
|
||||||
const event = name.slice(2) as keyof Events;
|
const event = name.slice(2) as keyof Events;
|
||||||
const listener = props[name as keyof Props];
|
const listener = props[name as keyof Props];
|
||||||
|
|
@ -20,8 +21,7 @@
|
||||||
|
|
||||||
const args = [event, listener as (...args: Events[typeof event]) => void] as const;
|
const args = [event, listener as (...args: Events[typeof event]) => void] as const;
|
||||||
|
|
||||||
eventManager.on(...args);
|
unsubscribes.push(eventManager.on(...args));
|
||||||
unsubscribes.push(() => eventManager.off(...args));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
|
import type { ThemeSetting } from '$lib/managers/theme-manager.svelte';
|
||||||
import type { ReleaseEvent } from '$lib/types';
|
import type { ReleaseEvent } from '$lib/types';
|
||||||
|
import { BaseEventManager } from '$lib/utils/base-event-manager.svelte';
|
||||||
import type { TreeNode } from '$lib/utils/tree-utils';
|
import type { TreeNode } from '$lib/utils/tree-utils';
|
||||||
import type {
|
import type {
|
||||||
AlbumResponseDto,
|
AlbumResponseDto,
|
||||||
|
|
@ -85,54 +86,4 @@ export type Events = {
|
||||||
ReleaseEvent: [ReleaseEvent];
|
ReleaseEvent: [ReleaseEvent];
|
||||||
};
|
};
|
||||||
|
|
||||||
type Listener<EventMap extends Record<string, unknown[]>, K extends keyof EventMap> = (...params: EventMap[K]) => void;
|
export const eventManager = new BaseEventManager<Events>();
|
||||||
|
|
||||||
class EventManager<EventMap extends Record<string, unknown[]>> {
|
|
||||||
private listeners: {
|
|
||||||
[K in keyof EventMap]?: {
|
|
||||||
listener: Listener<EventMap, K>;
|
|
||||||
once?: boolean;
|
|
||||||
}[];
|
|
||||||
} = {};
|
|
||||||
|
|
||||||
on<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
|
||||||
return this.addListener(key, listener, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
once<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void) {
|
|
||||||
return this.addListener(key, listener, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
off<K extends keyof EventMap>(key: K, listener: Listener<EventMap, K>) {
|
|
||||||
if (this.listeners[key]) {
|
|
||||||
this.listeners[key] = this.listeners[key].filter((item) => item.listener !== listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
|
|
||||||
emit<T extends keyof EventMap>(key: T, ...params: EventMap[T]) {
|
|
||||||
if (!this.listeners[key]) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { listener } of this.listeners[key]) {
|
|
||||||
listener(...params);
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove one time listeners
|
|
||||||
this.listeners[key] = this.listeners[key].filter((item) => !item.once);
|
|
||||||
}
|
|
||||||
|
|
||||||
private addListener<T extends keyof EventMap>(key: T, listener: (...params: EventMap[T]) => void, once: boolean) {
|
|
||||||
if (!this.listeners[key]) {
|
|
||||||
this.listeners[key] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
this.listeners[key].push({ listener, once });
|
|
||||||
|
|
||||||
return this;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const eventManager = new EventManager<Events>();
|
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ export class QueueManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on('QueueUpdate', () => void this.refresh());
|
eventManager.on('QueueUpdate', () => this.refresh());
|
||||||
}
|
}
|
||||||
|
|
||||||
listen() {
|
listen() {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class ServerConfigManager {
|
||||||
#value?: ServerConfigDto = $state();
|
#value?: ServerConfigDto = $state();
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on('SystemConfigUpdate', () => void this.loadServerConfig());
|
eventManager.on('SystemConfigUpdate', () => this.loadServerConfig());
|
||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
|
|
|
||||||
|
|
@ -113,9 +113,7 @@ export class TimelineManager extends VirtualScrollManager {
|
||||||
|
|
||||||
const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]);
|
const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]);
|
||||||
|
|
||||||
eventManager.on('AssetUpdate', onAssetUpdate);
|
this.#unsubscribes.push(eventManager.on('AssetUpdate', onAssetUpdate));
|
||||||
|
|
||||||
this.#unsubscribes.push(() => eventManager.off('AssetUpdate', onAssetUpdate));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override get scrollTop(): number {
|
override get scrollTop(): number {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,8 @@ class UploadManager {
|
||||||
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
|
mediaTypes = $state<ServerMediaTypesResponseDto>({ image: [], sidecar: [], video: [] });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on('AppInit', () => void this.#loadExtensions()).on('AuthLogout', () => void this.reset());
|
eventManager.on('AppInit', () => this.#loadExtensions());
|
||||||
|
eventManager.on('AuthLogout', () => this.reset());
|
||||||
}
|
}
|
||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ class MemoryStoreSvelte {
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on('AuthLogout', () => this.clearCache());
|
eventManager.on('AuthLogout', () => this.clearCache());
|
||||||
eventManager.on('AuthUserLoaded', () => void this.initialize());
|
eventManager.on('AuthUserLoaded', () => this.initialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
ready() {
|
ready() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { eventManager } from '$lib/managers/event-manager.svelte';
|
import { eventManager } from '$lib/managers/event-manager.svelte';
|
||||||
import { handlePromiseError } from '$lib/utils';
|
|
||||||
import { handleError } from '$lib/utils/handle-error';
|
import { handleError } from '$lib/utils/handle-error';
|
||||||
import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk';
|
import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk';
|
||||||
import { t } from 'svelte-i18n';
|
import { t } from 'svelte-i18n';
|
||||||
|
|
@ -9,7 +8,7 @@ class NotificationStore {
|
||||||
notifications = $state<NotificationDto[]>([]);
|
notifications = $state<NotificationDto[]>([]);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
eventManager.on('AuthLogin', () => handlePromiseError(this.refresh()));
|
eventManager.on('AuthLogin', () => this.refresh());
|
||||||
eventManager.on('AuthLogout', () => this.clear());
|
eventManager.on('AuthLogout', () => this.clear());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,11 @@
|
||||||
type BaseEvents = Record<string, unknown[]>;
|
type EventMap = Record<string, unknown[]>;
|
||||||
|
type PromiseLike<T> = Promise<T> | T;
|
||||||
|
|
||||||
export type EventCallback<Events extends BaseEvents, T extends keyof Events> = (
|
export type EventCallback<E extends EventMap, T extends keyof E> = (...args: E[T]) => PromiseLike<unknown>;
|
||||||
...args: Events[T]
|
export type EventItem<E extends EventMap, T extends keyof E = keyof E> = {
|
||||||
) => Promise<void> | void;
|
|
||||||
export type EventItem<Events extends BaseEvents, T extends keyof Events = keyof Events> = {
|
|
||||||
id: number;
|
id: number;
|
||||||
event: T;
|
event: T;
|
||||||
callback: EventCallback<Events, T>;
|
callback: EventCallback<E, T>;
|
||||||
};
|
};
|
||||||
|
|
||||||
let count = 1;
|
let count = 1;
|
||||||
|
|
@ -14,7 +13,7 @@ const nextId = () => count++;
|
||||||
|
|
||||||
const noop = () => {};
|
const noop = () => {};
|
||||||
|
|
||||||
export class BaseEventManager<Events extends BaseEvents> {
|
export class BaseEventManager<Events extends EventMap> {
|
||||||
#callbacks: EventItem<Events>[] = $state([]);
|
#callbacks: EventItem<Events>[] = $state([]);
|
||||||
|
|
||||||
on<T extends keyof Events>(event: T, callback?: EventCallback<Events, T>) {
|
on<T extends keyof Events>(event: T, callback?: EventCallback<Events, T>) {
|
||||||
|
|
|
||||||
|
|
@ -21,22 +21,5 @@ export function createEventEmitter<
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function once<Ev extends ReservedOrUserEventNames<ReservedEvents, ListenEvents>>(
|
return { on };
|
||||||
ev: Ev,
|
|
||||||
listener: ReservedOrUserListener<ReservedEvents, ListenEvents, Ev>,
|
|
||||||
) {
|
|
||||||
socket.once(ev, listener);
|
|
||||||
return () => {
|
|
||||||
socket.off(ev, listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function off<Ev extends ReservedOrUserEventNames<ReservedEvents, ListenEvents>>(
|
|
||||||
ev: Ev,
|
|
||||||
listener: ReservedOrUserListener<ReservedEvents, ListenEvents, Ev>,
|
|
||||||
) {
|
|
||||||
socket.off(ev, listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { on, once, off };
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue