refactor: event manager (#25481)

* refactor: event manager

* fix: broken downloadFile endpoint
This commit is contained in:
Jason Rasmussen 2026-01-23 18:02:23 -05:00 committed by GitHub
parent b52e8cd570
commit 4fedae4150
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 45 additions and 116 deletions

View file

@ -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

View file

@ -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

View file

@ -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();
} }

View file

@ -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', () => {

View file

@ -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({

View file

@ -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;

View file

@ -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 () => {

View file

@ -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>();

View file

@ -19,7 +19,7 @@ export class QueueManager {
} }
constructor() { constructor() {
eventManager.on('QueueUpdate', () => void this.refresh()); eventManager.on('QueueUpdate', () => this.refresh());
} }
listen() { listen() {

View file

@ -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() {

View file

@ -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 {

View file

@ -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() {

View file

@ -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() {

View file

@ -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());
} }

View file

@ -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>) {

View file

@ -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 };
} }