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);
+ }
+};