diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index fcd0fd8d5e..6d80849f7e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -504,16 +504,22 @@ jobs: CI: true run: npx playwright test --project=chromium 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) env: CI: true run: npx playwright test --project=ui if: ${{ !cancelled() }} - - name: Archive test results + - name: Archive ui results uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 if: success() || failure() with: - name: e2e-web-test-results-${{ matrix.runner }} + name: e2e-ui-test-results-${{ matrix.runner }} path: e2e/playwright-report/ success-check-e2e: name: End-to-End Tests Success diff --git a/server/src/queries/asset.repository.sql b/server/src/queries/asset.repository.sql index f564be1850..70d4602d26 100644 --- a/server/src/queries/asset.repository.sql +++ b/server/src/queries/asset.repository.sql @@ -601,15 +601,15 @@ where -- AssetRepository.getForThumbnail select - "asset_file"."path", "asset"."originalPath", - "asset"."originalFileName" + "asset"."originalFileName", + "asset_file"."path" as "path" from - "asset_file" - right join "asset" on "asset"."id" = "asset_file"."assetId" + "asset" + left join "asset_file" on "asset"."id" = "asset_file"."assetId" + and "asset_file"."type" = $1 where - "asset_file"."assetId" = $1 - and "asset_file"."type" = $2 + "asset"."id" = $2 order by "asset_file"."isEdited" desc diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index 63a397a59d..468ded4773 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -1033,12 +1033,12 @@ export class AssetRepository { @GenerateSql({ params: [DummyValue.UUID, AssetFileType.Preview] }) async getForThumbnail(id: string, type: AssetFileType) { return this.db - .selectFrom('asset_file') - .select('asset_file.path') - .where('asset_file.assetId', '=', id) - .where('asset_file.type', '=', type) - .rightJoin('asset', (join) => join.onRef('asset.id', '=', 'asset_file.assetId')) - .select(['asset.originalPath', 'asset.originalFileName']) + .selectFrom('asset') + .where('asset.id', '=', id) + .leftJoin('asset_file', (join) => + join.onRef('asset.id', '=', 'asset_file.assetId').on('asset_file.type', '=', type), + ) + .select(['asset.originalPath', 'asset.originalFileName', 'asset_file.path as path']) .orderBy('asset_file.isEdited', 'desc') .executeTakeFirstOrThrow(); } diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 91beeeb777..6c497e6098 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -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', () => { diff --git a/server/src/services/asset-media.service.ts b/server/src/services/asset-media.service.ts index ca86a43a4b..e944a1e345 100644 --- a/server/src/services/asset-media.service.ts +++ b/server/src/services/asset-media.service.ts @@ -201,10 +201,6 @@ export class AssetMediaService extends BaseService { dto.edited ?? false, ); - if (dto.edited && !editedPath) { - throw new NotFoundException('Edited asset media not found'); - } - const path = editedPath ?? originalPath!; return new ImmichFileResponse({ @@ -240,6 +236,10 @@ export class AssetMediaService extends BaseService { return { targetSize: AssetMediaSize.PREVIEW }; } + if (!path) { + throw new NotFoundException('Asset media not found'); + } + const fileName = `${getFileNameWithoutExtension(originalFileName)}_${size}${getFilenameExtension(path)}`; return new ImmichFileResponse({ diff --git a/web/src/lib/components/AssetViewerEvents.svelte b/web/src/lib/components/AssetViewerEvents.svelte index a33b74aab5..bf915ae217 100644 --- a/web/src/lib/components/AssetViewerEvents.svelte +++ b/web/src/lib/components/AssetViewerEvents.svelte @@ -8,9 +8,10 @@ }; const props: Props = $props(); - const unsubscribes: Array<() => void> = []; onMount(() => { + const unsubscribes: Array<() => void> = []; + for (const name of Object.keys(props)) { const event = name.slice(2) as keyof Events; const listener = props[name as keyof Props] as EventCallback | undefined; diff --git a/web/src/lib/components/OnEvents.svelte b/web/src/lib/components/OnEvents.svelte index 7f8039e6e3..aa137ccba1 100644 --- a/web/src/lib/components/OnEvents.svelte +++ b/web/src/lib/components/OnEvents.svelte @@ -7,9 +7,10 @@ }; const props: Props = $props(); - const unsubscribes: Array<() => void> = []; onMount(() => { + const unsubscribes: Array<() => void> = []; + for (const name of Object.keys(props)) { const event = name.slice(2) as keyof Events; const listener = props[name as keyof Props]; @@ -20,8 +21,7 @@ const args = [event, listener as (...args: Events[typeof event]) => void] as const; - eventManager.on(...args); - unsubscribes.push(() => eventManager.off(...args)); + unsubscribes.push(eventManager.on(...args)); } return () => { diff --git a/web/src/lib/managers/event-manager.svelte.ts b/web/src/lib/managers/event-manager.svelte.ts index 63d05d7241..322634f68c 100644 --- a/web/src/lib/managers/event-manager.svelte.ts +++ b/web/src/lib/managers/event-manager.svelte.ts @@ -1,5 +1,6 @@ import type { ThemeSetting } from '$lib/managers/theme-manager.svelte'; 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 { AlbumResponseDto, @@ -85,54 +86,4 @@ export type Events = { ReleaseEvent: [ReleaseEvent]; }; -type Listener, K extends keyof EventMap> = (...params: EventMap[K]) => void; - -class EventManager> { - private listeners: { - [K in keyof EventMap]?: { - listener: Listener; - once?: boolean; - }[]; - } = {}; - - on(key: T, listener: (...params: EventMap[T]) => void) { - return this.addListener(key, listener, false); - } - - once(key: T, listener: (...params: EventMap[T]) => void) { - return this.addListener(key, listener, true); - } - - off(key: K, listener: Listener) { - if (this.listeners[key]) { - this.listeners[key] = this.listeners[key].filter((item) => item.listener !== listener); - } - - return this; - } - - emit(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(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(); +export const eventManager = new BaseEventManager(); diff --git a/web/src/lib/managers/queue-manager.svelte.ts b/web/src/lib/managers/queue-manager.svelte.ts index a7950e455a..f6bd1ad052 100644 --- a/web/src/lib/managers/queue-manager.svelte.ts +++ b/web/src/lib/managers/queue-manager.svelte.ts @@ -19,7 +19,7 @@ export class QueueManager { } constructor() { - eventManager.on('QueueUpdate', () => void this.refresh()); + eventManager.on('QueueUpdate', () => this.refresh()); } listen() { diff --git a/web/src/lib/managers/server-config-manager.svelte.ts b/web/src/lib/managers/server-config-manager.svelte.ts index 2cde180274..e315fba818 100644 --- a/web/src/lib/managers/server-config-manager.svelte.ts +++ b/web/src/lib/managers/server-config-manager.svelte.ts @@ -5,7 +5,7 @@ class ServerConfigManager { #value?: ServerConfigDto = $state(); constructor() { - eventManager.on('SystemConfigUpdate', () => void this.loadServerConfig()); + eventManager.on('SystemConfigUpdate', () => this.loadServerConfig()); } async init() { diff --git a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts index 7625659e94..06601caff6 100644 --- a/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts +++ b/web/src/lib/managers/timeline-manager/timeline-manager.svelte.ts @@ -113,9 +113,7 @@ export class TimelineManager extends VirtualScrollManager { const onAssetUpdate = (asset: AssetResponseDto) => this.upsertAssets([toTimelineAsset(asset)]); - eventManager.on('AssetUpdate', onAssetUpdate); - - this.#unsubscribes.push(() => eventManager.off('AssetUpdate', onAssetUpdate)); + this.#unsubscribes.push(eventManager.on('AssetUpdate', onAssetUpdate)); } override get scrollTop(): number { diff --git a/web/src/lib/managers/upload-manager.svelte.ts b/web/src/lib/managers/upload-manager.svelte.ts index b51756678b..1b5b73ecd9 100644 --- a/web/src/lib/managers/upload-manager.svelte.ts +++ b/web/src/lib/managers/upload-manager.svelte.ts @@ -6,7 +6,8 @@ class UploadManager { mediaTypes = $state({ image: [], sidecar: [], video: [] }); constructor() { - eventManager.on('AppInit', () => void this.#loadExtensions()).on('AuthLogout', () => void this.reset()); + eventManager.on('AppInit', () => this.#loadExtensions()); + eventManager.on('AuthLogout', () => this.reset()); } reset() { diff --git a/web/src/lib/stores/memory.store.svelte.ts b/web/src/lib/stores/memory.store.svelte.ts index 357de394ae..623de24598 100644 --- a/web/src/lib/stores/memory.store.svelte.ts +++ b/web/src/lib/stores/memory.store.svelte.ts @@ -24,7 +24,7 @@ class MemoryStoreSvelte { constructor() { eventManager.on('AuthLogout', () => this.clearCache()); - eventManager.on('AuthUserLoaded', () => void this.initialize()); + eventManager.on('AuthUserLoaded', () => this.initialize()); } ready() { diff --git a/web/src/lib/stores/notification-manager.svelte.ts b/web/src/lib/stores/notification-manager.svelte.ts index a0f0f6bb93..03b160b989 100644 --- a/web/src/lib/stores/notification-manager.svelte.ts +++ b/web/src/lib/stores/notification-manager.svelte.ts @@ -1,5 +1,4 @@ import { eventManager } from '$lib/managers/event-manager.svelte'; -import { handlePromiseError } from '$lib/utils'; import { handleError } from '$lib/utils/handle-error'; import { getNotifications, updateNotification, updateNotifications, type NotificationDto } from '@immich/sdk'; import { t } from 'svelte-i18n'; @@ -9,7 +8,7 @@ class NotificationStore { notifications = $state([]); constructor() { - eventManager.on('AuthLogin', () => handlePromiseError(this.refresh())); + eventManager.on('AuthLogin', () => this.refresh()); eventManager.on('AuthLogout', () => this.clear()); } diff --git a/web/src/lib/utils/base-event-manager.svelte.ts b/web/src/lib/utils/base-event-manager.svelte.ts index f86d022337..50fa29b776 100644 --- a/web/src/lib/utils/base-event-manager.svelte.ts +++ b/web/src/lib/utils/base-event-manager.svelte.ts @@ -1,12 +1,11 @@ -type BaseEvents = Record; +type EventMap = Record; +type PromiseLike = Promise | T; -export type EventCallback = ( - ...args: Events[T] -) => Promise | void; -export type EventItem = { +export type EventCallback = (...args: E[T]) => PromiseLike; +export type EventItem = { id: number; event: T; - callback: EventCallback; + callback: EventCallback; }; let count = 1; @@ -14,7 +13,7 @@ const nextId = () => count++; const noop = () => {}; -export class BaseEventManager { +export class BaseEventManager { #callbacks: EventItem[] = $state([]); on(event: T, callback?: EventCallback) { diff --git a/web/src/lib/utils/eventemitter.ts b/web/src/lib/utils/eventemitter.ts index 35d8eecf87..0db6cd3777 100644 --- a/web/src/lib/utils/eventemitter.ts +++ b/web/src/lib/utils/eventemitter.ts @@ -21,22 +21,5 @@ export function createEventEmitter< }; } - function once>( - ev: Ev, - listener: ReservedOrUserListener, - ) { - socket.once(ev, listener); - return () => { - socket.off(ev, listener); - }; - } - - function off>( - ev: Ev, - listener: ReservedOrUserListener, - ) { - socket.off(ev, listener); - } - - return { on, once, off }; + return { on }; }