diff --git a/web/package-lock.json b/web/package-lock.json index 290dfaf23e..9b905b2dec 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -26,6 +26,7 @@ "intl-messageformat": "^10.7.11", "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", + "lru-cache": "^11.1.0", "luxon": "^3.4.4", "maplibre-gl": "^5.3.0", "pmtiles": "^4.3.0", @@ -6592,11 +6593,13 @@ "license": "MIT" }, "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.1.0.tgz", + "integrity": "sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==", + "license": "ISC", + "engines": { + "node": "20 || >=22" + } }, "node_modules/lru-queue": { "version": "0.1.0", @@ -7335,6 +7338,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", diff --git a/web/package.json b/web/package.json index a6e037d2e2..1e4aad9edf 100644 --- a/web/package.json +++ b/web/package.json @@ -43,6 +43,7 @@ "intl-messageformat": "^10.7.11", "justified-layout": "^4.1.0", "lodash-es": "^4.17.21", + "lru-cache": "^11.1.0", "luxon": "^3.4.4", "maplibre-gl": "^5.3.0", "pmtiles": "^4.3.0", diff --git a/web/src/lib/components/asset-viewer/asset-viewer.svelte b/web/src/lib/components/asset-viewer/asset-viewer.svelte index 68d0d4e32d..8e21a63923 100644 --- a/web/src/lib/components/asset-viewer/asset-viewer.svelte +++ b/web/src/lib/components/asset-viewer/asset-viewer.svelte @@ -105,7 +105,6 @@ let fullscreenElement = $state(); let unsubscribes: (() => void)[] = []; let selectedEditType: string = $state(''); - let stack: StackResponseDto | null = $state(null); let zoomToggle = $state(() => void 0); diff --git a/web/src/lib/managers/asset-manager/asset-manager.svelte.ts b/web/src/lib/managers/asset-manager/asset-manager.svelte.ts index 5550ff4b44..f90309cdb5 100644 --- a/web/src/lib/managers/asset-manager/asset-manager.svelte.ts +++ b/web/src/lib/managers/asset-manager/asset-manager.svelte.ts @@ -1,20 +1,10 @@ -import { authManager } from '$lib/managers/auth-manager.svelte'; -import type { TimelineAsset } from '$lib/managers/timeline-manager/types'; +import type { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte'; +import { loadFromAssetPackage } from '$lib/managers/asset-manager/internal/load-support.svelte'; import { CancellableTask } from '$lib/utils/cancellable-task'; import type { AssetGridRouteSearchParams } from '$lib/utils/navigation'; -import { toTimelineAsset } from '$lib/utils/timeline-util'; -import { - getAllAlbums, - getAssetInfo, - getAssetOriginalPath, - getAssetPlaybackPath, - getAssetThumbnailPath, - getBaseUrl, - type AlbumResponseDto, - type AssetResponseDto, -} from '@immich/sdk'; import { type ZoomImageWheelState } from '@zoom-image/core'; import { isEqual } from 'lodash-es'; +import { LRUCache } from 'lru-cache'; export enum AssetMediaSize { Original = 'original', @@ -24,28 +14,38 @@ export enum AssetMediaSize { Playback = 'playback', } -export type AssetManagerOptions = { - assetId?: string; - preloadAssetIds?: string[]; +export type LoadAssetOptions = { loadAlbums?: boolean; - size?: AssetMediaSize; + loadStack?: boolean; }; +export type AssetManagerOptions = {}; + export class AssetManager { isInitialized = $state(false); isLoaded = $state(false); loadError = $state(false); - asset: AssetResponseDto | undefined = $state(); - preloadAssets: TimelineAsset[] = $state([]); - albums: AlbumResponseDto[] = $state([]); + // The queue waited for load. The first is the currect and the next is preload. + // The preload asset is not need to loading immediately. + assetLoadingQueue: AssetPackage[] = $state([]); - cacheKey: string | null = $derived(this.asset?.thumbhash ?? null); + // url: string | undefined = $derived.by(() => { + // if (this.asset) { + // return this.#getAssetUrl(toTimelineAsset(this.asset!)); + // } + // }); - url: string | undefined = $derived.by(() => { - if (this.asset) { - return this.#getAssetUrl(toTimelineAsset(this.asset!)); - } - }); + #maximumLRUCache: number = $state(10); + + // TODO: This function is used to test. + dispose(value: AssetPackage, key: string) { + console.log(key); + console.log(value); + } + + assetCache: LRUCache = $state( + new LRUCache({ max: this.#maximumLRUCache, dispose: this.dispose }), + ); showAssetViewer: boolean = $state(false); gridScrollTarget: AssetGridRouteSearchParams | undefined = $state(); @@ -54,9 +54,8 @@ export class AssetManager { initTask = new CancellableTask( () => (this.isInitialized = true), () => { - this.asset = undefined; - this.preloadAssets = []; - this.albums = []; + this.assetLoadingQueue = []; + this.assetCache.clear(); this.isInitialized = false; }, () => void 0, @@ -65,28 +64,33 @@ export class AssetManager { static #INIT_OPTIONS = {}; #options: AssetManagerOptions = AssetManager.#INIT_OPTIONS; + static #DEFAULT_LOAD_ASSET_OPTIONS: LoadAssetOptions = { + loadAlbums: false, + loadStack: false, + }; + constructor() {} + async loadAssetPackage(options?: LoadAssetOptions, cancelable?: boolean): Promise { + cancelable = cancelable ?? true; + options = options ?? AssetManager.#DEFAULT_LOAD_ASSET_OPTIONS; + + const assetPackage = this.assetLoadingQueue[0]; + if (!assetPackage) { + return; + } + + if (assetPackage.loader?.executed) { + return; + } + + const result = await assetPackage.loader?.execute(async (signal: AbortSignal) => { + await loadFromAssetPackage(this, assetPackage, options, signal); + }, cancelable); + } + async #initializeAsset() { - if (this.#options.assetId) { - const assetResponse = await getAssetInfo({ id: this.#options.assetId, key: authManager.key }); - if (!assetResponse) { - return; - } - this.asset = assetResponse; - } else { - throw new Error('The assetId in required in options.'); - } - // TODO: Preload assets. - - if (this.#options.loadAlbums ?? true) { - const albumsResponse = await getAllAlbums({ assetId: this.#options.assetId }); - if (!albumsResponse) { - return; - } - this.albums = albumsResponse; - } } async updateOptions(options: AssetManagerOptions) { @@ -97,27 +101,13 @@ export class AssetManager { await this.#init(options); } - #checkOptions() { - this.#options.size = AssetMediaSize.Original; - - if (!this.asset || !this.zoomImageState) { - return; - } - - if (this.asset.originalMimeType === 'image/gif' || this.zoomImageState.currentZoom > 1) { - // TODO: use original image forcely and according to the setting. - } - } - async #init(options: AssetManagerOptions) { this.isInitialized = false; - this.asset = undefined; - this.preloadAssets = []; - this.albums = []; + this.assetLoadingQueue = []; + this.assetCache.clear(); await this.initTask.execute(async () => { this.#options = options; await this.#initializeAsset(); - this.#checkOptions(); }, true); } @@ -125,63 +115,71 @@ export class AssetManager { this.isInitialized = false; } - async refreshAlbums() {} + // #checkOptions() { + // this.#options.size = AssetMediaSize.Original; - async refreshAsset() {} + // if (!this.asset || !this.zoomImageState) { + // return; + // } - #preload() { - for (const preloadAsset of this.preloadAssets) { - if (preloadAsset.isImage) { - let img = new Image(); - const preloadUrl = this.#getAssetUrl(preloadAsset); - if (preloadUrl) { - img.src = preloadUrl; - } else { - throw new Error('AssetManager is not initialized.'); - } - } - } - } + // if (this.asset.originalMimeType === 'image/gif' || this.zoomImageState.currentZoom > 1) { + // // TODO: use original image forcely and according to the setting. + // } + // } - #getAssetUrl(asset: TimelineAsset) { - if (!this.asset) { - return; - } + // #preload() { + // for (const preloadAsset of this.preloadAssets) { + // if (preloadAsset.isImage) { + // let img = new Image(); + // const preloadUrl = this.#getAssetUrl(preloadAsset); + // if (preloadUrl) { + // img.src = preloadUrl; + // } else { + // throw new Error('AssetManager is not initialized.'); + // } + // } + // } + // } - let path = undefined; - const searchParameters = new URLSearchParams(); - if (authManager.key) { - searchParameters.set('key', authManager.key); - } - if (this.cacheKey) { - searchParameters.set('c', this.cacheKey); - } + // #getAssetUrl(asset: TimelineAsset) { + // if (!this.asset) { + // return; + // } - switch (this.#options.size) { - case AssetMediaSize.Original: { - path = getAssetOriginalPath(this.asset.id); - break; - } - case AssetMediaSize.Fullsize: - case AssetMediaSize.Thumbnail: - case AssetMediaSize.Preview: { - path = getAssetThumbnailPath(this.asset.id); - break; - } - case AssetMediaSize.Playback: { - path = getAssetPlaybackPath(this.asset.id); - break; - } - default: - // TODO: default AssetMediaSize - } + // let path = undefined; + // const searchParameters = new URLSearchParams(); + // if (authManager.key) { + // searchParameters.set('key', authManager.key); + // } + // if (this.cacheKey) { + // searchParameters.set('c', this.cacheKey); + // } - return getBaseUrl() + path + '?' + searchParameters.toString(); - } + // switch (this.#options.size) { + // case AssetMediaSize.Original: { + // path = getAssetOriginalPath(this.asset.id); + // break; + // } + // case AssetMediaSize.Fullsize: + // case AssetMediaSize.Thumbnail: + // case AssetMediaSize.Preview: { + // path = getAssetThumbnailPath(this.asset.id); + // break; + // } + // case AssetMediaSize.Playback: { + // path = getAssetPlaybackPath(this.asset.id); + // break; + // } + // default: + // // TODO: default AssetMediaSize + // } - get isOriginalImage() { - return this.#options.size === AssetMediaSize.Original || this.#options.size === AssetMediaSize.Fullsize; - } + // return getBaseUrl() + path + '?' + searchParameters.toString(); + // } + + // get isOriginalImage() { + // return this.#options.size === AssetMediaSize.Original || this.#options.size === AssetMediaSize.Fullsize; + // } } // const getAssetUrl = (id: string, targetSize: AssetMediaSize | 'original', cacheKey: string | null) => { diff --git a/web/src/lib/managers/asset-manager/asset-package.svelte.ts b/web/src/lib/managers/asset-manager/asset-package.svelte.ts new file mode 100644 index 0000000000..16fd655120 --- /dev/null +++ b/web/src/lib/managers/asset-manager/asset-package.svelte.ts @@ -0,0 +1,48 @@ +import { CancellableTask } from '$lib/utils/cancellable-task'; +import { handleError } from '$lib/utils/handle-error'; +import type { AlbumResponseDto, AssetResponseDto, StackResponseDto } from '@immich/sdk'; +import { t } from 'svelte-i18n'; +import { get } from 'svelte/store'; +import type { AssetManager, AssetManagerOptions } from './asset-manager.svelte'; + +export class AssetPackage { + isLoaded: boolean = $state(false); + asset: AssetResponseDto | undefined = $state(); + albums: AlbumResponseDto[] = $state([]); + stack: StackResponseDto | undefined = $state(); + readonly assetId: string; + readonly assetManager: AssetManager; + + // To ensure albums and stack is need to reloading. + options: AssetManagerOptions | undefined = $state(); + + loader: CancellableTask | undefined; + + constructor(store: AssetManager, assetId: string) { + this.assetManager = store; + this.assetId = assetId; + + this.loader = new CancellableTask( + () => { + this.isLoaded = true; + }, + () => { + this.asset = undefined; + this.albums = []; + this.stack = undefined; + this.isLoaded = false; + }, + this.#handleLoadError, + ); + } + + // TODO: Add error message to translation. + #handleLoadError(error: unknown) { + const _$t = get(t); + handleError(error, _$t('errors.failed_to_load_asset')); + } + + cancel() { + this.loader?.cancel(); + } +} diff --git a/web/src/lib/managers/asset-manager/internal/load-support.svelte.ts b/web/src/lib/managers/asset-manager/internal/load-support.svelte.ts index eebf28e120..a8dfba668a 100644 --- a/web/src/lib/managers/asset-manager/internal/load-support.svelte.ts +++ b/web/src/lib/managers/asset-manager/internal/load-support.svelte.ts @@ -1,5 +1,69 @@ -import type { AssetManager } from '$lib/managers/asset-manager/asset-manager.svelte'; -import { cancelImageUrl } from '$lib/utils/sw-messaging'; +import type { AssetManager, LoadAssetOptions } from '$lib/managers/asset-manager/asset-manager.svelte'; +import { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte'; +import { authManager } from '$lib/managers/auth-manager.svelte'; +// import { cancelImageUrl } from '$lib/utils/sw-messaging'; +import { getAllAlbums, getAssetInfo, getStack } from '@immich/sdk'; + +export async function loadFromAssetPackage( + assetManager: AssetManager, + assetPackage: AssetPackage, + options: LoadAssetOptions, + signal: AbortSignal, +): Promise { + const assetId = assetPackage.assetId; + const assetCache = assetManager.assetCache.get(assetId); + if (assetCache && assetCache.options === options) { + return; + } + + // TODO: Compare between assetCache and assetCache.options to ensure whether we need update or not. + + // If there is assetCache, then asset info is not need to update. + if (!assetCache) { + const key = authManager.key; + const assetResponse = await getAssetInfo( + { + id: assetId, + key, + }, + { signal }, + ); + + if (!assetResponse) { + throw new Error('get AssetInfo error'); + } + assetPackage.asset = assetResponse; + } + + // TODO: need to update albums + if (options.loadAlbums) { + const albumsResponse = await getAllAlbums( + { + assetId, + }, + { signal }, + ); + + if (!albumsResponse) { + throw new Error('get AllAlbums error'); + } + assetPackage.albums = albumsResponse; + } + + if (options.loadStack) { + const stackResponse = await getStack( + { + id: assetId, + }, + { signal }, + ); + + if (!stackResponse) { + throw new Error('get Stack error'); + } + assetPackage.stack = stackResponse; + } +} export function mediaLoaded(assetManager: AssetManager) { assetManager.isLoaded = true; @@ -9,9 +73,9 @@ export function mediaLoadError(assetManager: AssetManager) { assetManager.isLoaded = assetManager.loadError = true; } -export function cancelImageLoad(assetManager: AssetManager) { - if (assetManager.url) { - cancelImageUrl(assetManager.url); - } - assetManager.isLoaded = assetManager.loadError = false; -} +// export function cancelImageLoad(assetManager: AssetManager) { +// if (assetManager.url) { +// cancelImageUrl(assetManager.url); +// } +// assetManager.isLoaded = assetManager.loadError = false; +// } diff --git a/web/src/lib/managers/asset-manager/utils.svelte.ts b/web/src/lib/managers/asset-manager/utils.svelte.ts new file mode 100644 index 0000000000..769433fac2 --- /dev/null +++ b/web/src/lib/managers/asset-manager/utils.svelte.ts @@ -0,0 +1,3 @@ +import type { AssetPackage } from '$lib/managers/asset-manager/asset-package.svelte'; + +export const assetPackage = (assetPackage: AssetPackage): AssetPackage => $state.snapshot(assetPackage);