diff --git a/Makefile b/Makefile index e15faa8051..1e7760ae68 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,9 @@ e2e: prod: docker compose -f ./docker/docker-compose.prod.yml up --build -V --remove-orphans +prod-down: + docker compose -f ./docker/docker-compose.prod.yml down --remove-orphans + prod-scale: docker compose -f ./docker/docker-compose.prod.yml up --build -V --scale immich-server=3 --scale immich-microservices=3 --remove-orphans diff --git a/e2e/src/web/specs/photo-viewer.e2e-spec.ts b/e2e/src/web/specs/photo-viewer.e2e-spec.ts index 4871e7522c..c8a9b42b2a 100644 --- a/e2e/src/web/specs/photo-viewer.e2e-spec.ts +++ b/e2e/src/web/specs/photo-viewer.e2e-spec.ts @@ -21,23 +21,9 @@ test.describe('Photo Viewer', () => { test.beforeEach(async ({ context, page }) => { // before each test, login as user await utils.setAuthCookies(context, admin.accessToken); - await page.goto('/photos'); await page.waitForLoadState('networkidle'); }); - test('initially shows a loading spinner', async ({ page }) => { - await page.route(`/api/assets/${asset.id}/thumbnail**`, async (route) => { - // slow down the request for thumbnail, so spinner has chance to show up - await new Promise((f) => setTimeout(f, 2000)); - await route.continue(); - }); - await page.goto(`/photos/${asset.id}`); - await page.waitForLoadState('load'); - // this is the spinner - await page.waitForSelector('svg[role=status]'); - await expect(page.getByTestId('loading-spinner')).toBeVisible(); - }); - test('loads original photo when zoomed', async ({ page }) => { await page.goto(`/photos/${asset.id}`); await expect.poll(async () => await imageLocator(page).getAttribute('src')).toContain('thumbnail'); diff --git a/web/eslint.config.js b/web/eslint.config.js index 5c24cd1aeb..9ced619504 100644 --- a/web/eslint.config.js +++ b/web/eslint.config.js @@ -58,6 +58,8 @@ export default typescriptEslint.config( }, }, + ignores: ['**/service-worker/**'], + rules: { '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/web/src/lib/components/asset-viewer/photo-viewer.svelte b/web/src/lib/components/asset-viewer/photo-viewer.svelte index fdb986786e..531f075b86 100644 --- a/web/src/lib/components/asset-viewer/photo-viewer.svelte +++ b/web/src/lib/components/asset-viewer/photo-viewer.svelte @@ -21,6 +21,7 @@ import FaceEditor from '$lib/components/asset-viewer/face-editor/face-editor.svelte'; import { photoViewerImgElement } from '$lib/stores/assets-store.svelte'; import { isFaceEditMode } from '$lib/stores/face-edit.svelte'; + import { cancelImageUrl, preloadImageUrl } from '$lib/utils/sw-messaging'; interface Props { asset: AssetResponseDto; @@ -71,8 +72,7 @@ const preload = (targetSize: AssetMediaSize | 'original', preloadAssets?: AssetResponseDto[]) => { for (const preloadAsset of preloadAssets || []) { if (preloadAsset.type === AssetTypeEnum.Image) { - let img = new Image(); - img.src = getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash); + preloadImageUrl(getAssetUrl(preloadAsset.id, targetSize, preloadAsset.thumbhash)); } } }; @@ -168,6 +168,7 @@ return () => { loader?.removeEventListener('load', onload); loader?.removeEventListener('error', onerror); + cancelImageUrl(imageLoaderUrl); }; }); diff --git a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte index 2e8ad6ca32..04493b273c 100644 --- a/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/image-thumbnail.svelte @@ -2,9 +2,11 @@ import { thumbhash } from '$lib/actions/thumbhash'; import BrokenAsset from '$lib/components/assets/broken-asset.svelte'; import Icon from '$lib/components/elements/icon.svelte'; + import { cancelImageUrl } from '$lib/utils/sw-messaging'; import { TUNABLES } from '$lib/utils/tunables'; import { mdiEyeOffOutline } from '@mdi/js'; import type { ClassValue } from 'svelte/elements'; + import type { ActionReturn } from 'svelte/action'; import { fade } from 'svelte/transition'; interface Props { @@ -59,11 +61,14 @@ onComplete?.(true); }; - function mount(elem: HTMLImageElement) { + function mount(elem: HTMLImageElement): ActionReturn { if (elem.complete) { loaded = true; onComplete?.(false); } + return { + destroy: () => cancelImageUrl(url), + }; } let optionalClasses = $derived( diff --git a/web/src/lib/utils/sw-messaging.ts b/web/src/lib/utils/sw-messaging.ts new file mode 100644 index 0000000000..1a19d3c134 --- /dev/null +++ b/web/src/lib/utils/sw-messaging.ts @@ -0,0 +1,8 @@ +const broadcast = new BroadcastChannel('immich'); + +export function cancelImageUrl(url: string) { + broadcast.postMessage({ type: 'cancel', url }); +} +export function preloadImageUrl(url: string) { + broadcast.postMessage({ type: 'preload', url }); +} diff --git a/web/src/service-worker/index.ts b/web/src/service-worker/index.ts new file mode 100644 index 0000000000..797f4754b6 --- /dev/null +++ b/web/src/service-worker/index.ts @@ -0,0 +1,86 @@ +/// +/// +/// +/// +import { version } from '$service-worker'; + +const useCache = true; +const sw = globalThis as unknown as ServiceWorkerGlobalScope; +const pendingLoads = new Map(); + +// Create a unique cache name for this deployment +const CACHE = `cache-${version}`; + +sw.addEventListener('install', (event) => { + event.waitUntil(sw.skipWaiting()); +}); + +sw.addEventListener('activate', (event) => { + event.waitUntil(sw.clients.claim()); + // Remove previous cached data from disk + event.waitUntil(deleteOldCaches()); +}); + +sw.addEventListener('fetch', (event) => { + if (event.request.method !== 'GET') { + return; + } + const url = new URL(event.request.url); + if (/^\/api\/assets\/[a-f0-9-]+\/(original|thumbnail)/.test(url.pathname)) { + event.respondWith(immichAsset(url)); + } +}); + +async function deleteOldCaches() { + for (const key of await caches.keys()) { + if (key !== CACHE) { + await caches.delete(key); + } + } +} + +async function immichAsset(url: URL) { + const cache = await caches.open(CACHE); + let response = useCache ? await cache.match(url) : undefined; + if (response) { + return response; + } + try { + const cancelToken = new AbortController(); + const request = fetch(url, { + signal: cancelToken.signal, + }); + pendingLoads.set(url.toString(), cancelToken); + response = await request; + if (!(response instanceof Response)) { + throw new TypeError('invalid response from fetch'); + } + if (response.status === 200) { + cache.put(url, response.clone()); + } + return response; + } catch { + return Response.error(); + } finally { + pendingLoads.delete(url.toString()); + } +} + +const broadcast = new BroadcastChannel('immich'); +// eslint-disable-next-line unicorn/prefer-add-event-listener +broadcast.onmessage = (event) => { + if (!event.data) { + return; + } + const urlstring = event.data.url; + const url = new URL(urlstring, event.origin); + if (event.data.type === 'cancel') { + const pending = pendingLoads.get(url.toString()); + if (pending) { + pending.abort(); + pendingLoads.delete(url.toString()); + } + } else if (event.data.type === 'preload') { + immichAsset(url); + } +};