diff --git a/web/apps/photos/src/components/FixCreationTime.tsx b/web/apps/photos/src/components/FixCreationTime.tsx index 35aa71f87f..b060d33d6c 100644 --- a/web/apps/photos/src/components/FixCreationTime.tsx +++ b/web/apps/photos/src/components/FixCreationTime.tsx @@ -324,8 +324,7 @@ const updateEnteFileDate = async ( timestamp: customDate!.timestamp, }; } else if (enteFile.metadata.fileType == FileType.image) { - const stream = await downloadManager.getFile(enteFile); - const blob = await new Response(stream).blob(); + const blob = await downloadManager.fileBlob(enteFile); const file = new File([blob], enteFile.metadata.title); const { DateTimeOriginal, DateTimeDigitized, MetadataDate, DateTime } = await extractExifDates(file); diff --git a/web/apps/photos/src/components/PhotoFrame.tsx b/web/apps/photos/src/components/PhotoFrame.tsx index 330d67cb35..607067d953 100644 --- a/web/apps/photos/src/components/PhotoFrame.tsx +++ b/web/apps/photos/src/components/PhotoFrame.tsx @@ -362,7 +362,9 @@ const PhotoFrame = ({ } log.info(`[${item.id}] doesn't have thumbnail`); thumbFetching[item.id] = true; - const url = await DownloadManager.getThumbnailForPreview(item); + // URL will always be defined (unless an error is thrown) since + // we are not passing the `cachedOnly` option. + const url = await DownloadManager.renderableThumbnailURL(item)!; updateThumb(instance, index, item, url, false); } catch (e) { log.error("getSlideData failed get msrc url failed", e); diff --git a/web/apps/photos/src/components/PhotoList/index.tsx b/web/apps/photos/src/components/PhotoList/index.tsx index 27dfcda6d9..a6dfaa3a5a 100644 --- a/web/apps/photos/src/components/PhotoList/index.tsx +++ b/web/apps/photos/src/components/PhotoList/index.tsx @@ -631,10 +631,6 @@ export function PhotoList({ /** * Checks and merge multiple dates into a single row. - * - * @param items - * @param columns - * @returns */ const mergeTimeStampList = ( items: TimeStampListItem[], diff --git a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx index 936152561e..a84709b010 100644 --- a/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx +++ b/web/apps/photos/src/components/pages/gallery/PreviewCard.tsx @@ -274,7 +274,7 @@ export default function PreviewCard(props: IProps) { return; } const url: string = - await DownloadManager.getThumbnailForPreview( + await DownloadManager.renderableThumbnailURL( file, props.showPlaceholder, ); diff --git a/web/apps/photos/src/services/export/index.ts b/web/apps/photos/src/services/export/index.ts index 5ec3dea3ba..78cec8c197 100644 --- a/web/apps/photos/src/services/export/index.ts +++ b/web/apps/photos/src/services/export/index.ts @@ -928,7 +928,7 @@ class ExportService { const electron = ensureElectron(); try { const fileUID = getExportRecordFileUID(file); - const originalFileStream = await downloadManager.getFile(file); + const originalFileStream = await downloadManager.fileStream(file); if (file.metadata.fileType === FileType.livePhoto) { await this.exportLivePhoto( exportDir, diff --git a/web/apps/photos/src/services/export/migration.ts b/web/apps/photos/src/services/export/migration.ts index 2224ac73dc..1578085566 100644 --- a/web/apps/photos/src/services/export/migration.ts +++ b/web/apps/photos/src/services/export/migration.ts @@ -309,8 +309,7 @@ async function getFileExportNamesFromExportedFiles( For Live Photos we need to download the file to get the image and video name */ if (file.metadata.fileType === FileType.livePhoto) { - const fileStream = await downloadManager.getFile(file); - const fileBlob = await new Response(fileStream).blob(); + const fileBlob = await downloadManager.fileBlob(file); const { imageFileName, videoFileName } = await decodeLivePhoto( file.metadata.title, fileBlob, diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts index 12b6e6a69b..ce5740039f 100644 --- a/web/apps/photos/src/utils/file/index.ts +++ b/web/apps/photos/src/utils/file/index.ts @@ -56,9 +56,7 @@ export enum FILE_OPS_TYPE { export async function downloadFile(file: EnteFile) { try { - let fileBlob = await new Response( - await DownloadManager.getFile(file), - ).blob(); + let fileBlob = await DownloadManager.fileBlob(file); if (file.metadata.fileType === FileType.livePhoto) { const { imageFileName, imageData, videoFileName, videoData } = await decodeLivePhoto(file.metadata.title, fileBlob); @@ -392,7 +390,7 @@ async function downloadFileDesktop( ) { const fs = electron.fs; - const stream = await DownloadManager.getFile(file); + const stream = await DownloadManager.fileStream(file); if (file.metadata.fileType === FileType.livePhoto) { const fileBlob = await new Response(stream).blob(); diff --git a/web/packages/new/photos/components/Tiles.tsx b/web/packages/new/photos/components/Tiles.tsx index 134b3b8d1f..18bf6a7aa1 100644 --- a/web/packages/new/photos/components/Tiles.tsx +++ b/web/packages/new/photos/components/Tiles.tsx @@ -72,7 +72,7 @@ export const ItemCard: React.FC> = ({ ); } else { void downloadManager - .getThumbnailForPreview(coverFile, isScrolling) + .renderableThumbnailURL(coverFile, isScrolling) .then((url) => !didCancel && setCoverImageURL(url)); } diff --git a/web/packages/new/photos/services/download.ts b/web/packages/new/photos/services/download.ts index 2fcb7489f3..406411f1d6 100644 --- a/web/packages/new/photos/services/download.ts +++ b/web/packages/new/photos/services/download.ts @@ -78,21 +78,34 @@ interface DownloadClient { class DownloadManagerImpl { private ready = false; private downloadClient: DownloadClient | undefined; - /** Local cache for thumbnails. Might not be available. */ - private thumbnailCache?: BlobCache; /** - * Local cache for the files themselves. + * Local cache for thumbnail blobs. * - * Only available when we're running in the desktop app. + * Might not be available. */ - private fileCache?: BlobCache; - - private fileObjectURLPromises = new Map>(); - private fileConversionPromises = new Map>(); - private thumbnailObjectURLPromises = new Map< + private thumbnailCache: BlobCache | undefined; + /** + * An in-memory cache for an object URL to a file's thumbnail. + * + * This object URL can be directly used to render the thumbnail (e.g. in an + * img tag). The entries are keyed by the file ID. + */ + private thumbnailURLPromises = new Map< number, Promise >(); + /** + * An in-memory cache for an object URL to a file's original data. + * + * Unlike {@link thumbnailURLPromises}, there is no guarantee that the + * browser will be able to render the original file (e.g. it might be in an + * unsupported format). If a renderable URL is needed for the file, + * {@link renderableFileData} should be used instead. + * + * The entries are keyed by the file ID. + */ + private fileURLPromises = new Map>(); + private fileConversionPromises = new Map>(); private fileDownloadProgress = new Map(); @@ -112,13 +125,6 @@ class DownloadManagerImpl { e, ); } - // TODO (MR): Revisit full file caching cf disk space usage - // try { - // if (isElectron()) this.fileCache = await cache("files"); - // } catch (e) { - // log.error("Failed to open file cache, will continue without it", e); - // } - this.ready = true; } @@ -134,9 +140,9 @@ class DownloadManagerImpl { logout() { this.ready = false; this.downloadClient = undefined; - this.fileObjectURLPromises.clear(); + this.thumbnailURLPromises.clear(); + this.fileURLPromises.clear(); this.fileConversionPromises.clear(); - this.thumbnailObjectURLPromises.clear(); this.fileDownloadProgress.clear(); this.progressUpdater = () => {}; } @@ -150,6 +156,71 @@ class DownloadManagerImpl { this.progressUpdater = progressUpdater; } + /** + * Resolves with an URL that points to the file's thumbnail. + * + * The thumbnail will be downloaded if needed (unless {@link cachedOnly} is + * true). It will also be cached for subsequent fetches. + * + * The optional {@link cachedOnly} parameter can be set to indicate that + * this is being called as part of a scroll, so the downloader should not + * attempt to download the file but should instead fulfill the request from + * the disk cache. This avoids an unbounded flurry of requests on scroll, + * only downloading when the position has quiescized. + * + * The returned URL is actually an object URL, but it should not be revoked + * since the download manager caches it for future use. + */ + async renderableThumbnailURL( + file: EnteFile, + cachedOnly = false, + ): Promise { + this.ensureInitialized(); + + if (!this.thumbnailURLPromises.has(file.id)) { + const url = this.thumbnailData(file, cachedOnly).then((data) => + data ? URL.createObjectURL(new Blob([data])) : undefined, + ); + this.thumbnailURLPromises.set(file.id, url); + } + + let thumb = await this.thumbnailURLPromises.get(file.id); + if (cachedOnly) return thumb; + + if (!thumb) { + this.thumbnailURLPromises.delete(file.id); + thumb = await this.renderableThumbnailURL(file); + } + return thumb; + } + + /** + * Returns the thumbnail data for a file, downloading it if needed. + * + * The data is cached on disk for subsequent fetches. + * + * @param file The {@link EnteFile} whose thumbnail we want. + * + * @param cachedOnly If true, then the thumbnail is not downloaded if it is + * not already present in the disk cache. + * + * @returns The bytes of the thumbnail, as a {@link Uint8Array}. This method + * can return `undefined` iff the thumbnail is not already cached, and + * {@link cachedOnly} is set to `true`. + */ + async thumbnailData(file: EnteFile, cachedOnly = false) { + this.ensureInitialized(); + + const key = file.id.toString(); + const cached = await this.thumbnailCache?.get(key); + if (cached) return new Uint8Array(await cached.arrayBuffer()); + if (cachedOnly) return undefined; + + const thumb = await this.downloadThumb(file); + await this.thumbnailCache?.put(key, new Blob([thumb])); + return thumb; + } + private downloadThumb = async (file: EnteFile) => { const { downloadClient } = this.ensureInitialized(); @@ -158,60 +229,6 @@ class DownloadManagerImpl { return decryptThumbnail({ encryptedData, decryptionHeader }, file.key); }; - async getThumbnail(file: EnteFile, localOnly = false) { - this.ensureInitialized(); - - const key = file.id.toString(); - const cached = await this.thumbnailCache?.get(key); - if (cached) return new Uint8Array(await cached.arrayBuffer()); - if (localOnly) return undefined; - - const thumb = await this.downloadThumb(file); - await this.thumbnailCache?.put(key, new Blob([thumb])); - return thumb; - } - - /** - * Resolves with an URL that points to the file's thumbnail. - * - * The thumbnail will be downloaded (unless {@link localOnly} is true) and - * cached. - * - * The optional {@link localOnly} parameter can be set to indicate that this - * is being called as part of a scroll, so the downloader should not attempt - * to download the file but should instead fulfill the request from the - * cache. This avoids an unbounded flurry of requests on scroll, only - * downloading when the position has quiescized. - * - * The returned URL is actually an object URL, but it should not be revoked - * since the download manager caches it for future use. - */ - async getThumbnailForPreview( - file: EnteFile, - localOnly = false, - ): Promise { - this.ensureInitialized(); - try { - if (!this.thumbnailObjectURLPromises.has(file.id)) { - const thumbPromise = this.getThumbnail(file, localOnly); - const thumbURLPromise = thumbPromise.then( - (thumb) => thumb && URL.createObjectURL(new Blob([thumb])), - ); - this.thumbnailObjectURLPromises.set(file.id, thumbURLPromise); - } - let thumb = await this.thumbnailObjectURLPromises.get(file.id); - if (!thumb && !localOnly) { - this.thumbnailObjectURLPromises.delete(file.id); - thumb = await this.getThumbnailForPreview(file, localOnly); - } - return thumb; - } catch (e) { - this.thumbnailObjectURLPromises.delete(file.id); - log.error("get DownloadManager preview Failed", e); - throw e; - } - } - /** * The `forceConvert` option is true when the user presses the "Convert" * button. See: [Note: Forcing conversion of playable videos]. @@ -224,19 +241,14 @@ class DownloadManagerImpl { try { const forceConvert = opts?.forceConvert ?? false; const getFileForPreviewPromise = async () => { - const fileBlob = await new Response( - await this.getFile(file, true), - ).blob(); - // TODO: Is this ensure valid? - // The existing code was already dereferencing, so it shouldn't - // affect behaviour. - const { url: originalFileURL } = - (await this.fileObjectURLPromises.get(file.id))!; - + const originalFileURL = + await this.fileURLDownloadAndCacheIfNeeded(file); + const res = await fetch(originalFileURL); + const fileBlob = await res.blob(); const converted = await getRenderableFileURL( file, fileBlob, - originalFileURL as string, + originalFileURL, forceConvert, ); return converted; @@ -256,41 +268,64 @@ class DownloadManagerImpl { } }; - async getFile( + /** + * Return a blob to the file's contents, downloading it needed. + * + * This is a convenience abstraction over {@link fileStream} that converts + * it into a {@link Blob}. + */ + async fileBlob(file: EnteFile) { + return this.fileStream(file).then((s) => new Response(s).blob()); + } + + /** + * Return an stream to the file's contents, downloading it needed. + * + * Note that the results are not cached in-memory. That is, while the + * request may be served from the existing item in the in-memory cache, if + * it is not found and a download is required, that result will not be + * cached for subsequent use. + * + * @param file The {@link EnteFile} whose data we want. + */ + async fileStream( file: EnteFile, - cacheInMemory = false, ): Promise | null> { this.ensureInitialized(); + + const cachedURL = this.fileURLPromises.get(file.id); + if (cachedURL) { + try { + const url = await cachedURL; + const res = await fetch(url); + return res.body; + } catch (e) { + log.warn("Failed to use cached object URL", e); + this.fileURLPromises.delete(file.id); + } + } + + return this.downloadFile(file); + } + + /** + * A private variant of {@link fileStream} that also caches the results. + */ + private async fileURLDownloadAndCacheIfNeeded(file: EnteFile) { + this.ensureInitialized(); + + const cachedURL = this.fileURLPromises.get(file.id); + if (cachedURL) return cachedURL; + + const url = this.downloadFile(file) + .then((stream) => new Response(stream).blob()) + .then((blob) => URL.createObjectURL(blob)); + this.fileURLPromises.set(file.id, url); + try { - const getFilePromise = async (): Promise => { - const fileStream = await this.downloadFile(file); - const fileBlob = await new Response(fileStream).blob(); - return { - url: URL.createObjectURL(fileBlob), - isOriginal: true, - isRenderable: false, - type: "normal", - }; - }; - if (!this.fileObjectURLPromises.has(file.id)) { - if (!cacheInMemory) { - return await this.downloadFile(file); - } - this.fileObjectURLPromises.set(file.id, getFilePromise()); - } - // TODO: Is this ensure valid? - // The existing code was already dereferencing, so it shouldn't - // affect behaviour. - const fileURLs = (await this.fileObjectURLPromises.get(file.id))!; - if (fileURLs.isOriginal) { - const fileStream = (await fetch(fileURLs.url as string)).body; - return fileStream; - } else { - return await this.downloadFile(file); - } + return await url; } catch (e) { - this.fileObjectURLPromises.delete(file.id); - log.error("download manager getFile Failed", e); + this.fileURLPromises.delete(file.id); throw e; } } @@ -308,25 +343,15 @@ class DownloadManagerImpl { file.info?.fileSize ?? 0, ); - const cacheKey = file.id.toString(); - if ( file.metadata.fileType === FileType.image || file.metadata.fileType === FileType.livePhoto ) { - const cachedBlob = await this.fileCache?.get(cacheKey); - let encryptedArrayBuffer = await cachedBlob?.arrayBuffer(); - if (!encryptedArrayBuffer) { - const array = await downloadClient.downloadFile( - file, - onDownloadProgress, - ); - encryptedArrayBuffer = array.buffer; - await this.fileCache?.put( - cacheKey, - new Blob([encryptedArrayBuffer]), - ); - } + const array = await downloadClient.downloadFile( + file, + onDownloadProgress, + ); + const encryptedArrayBuffer = array.buffer; this.clearDownloadProgress(file.id); const decrypted = await decryptStreamBytes( @@ -339,18 +364,7 @@ class DownloadManagerImpl { return new Response(decrypted).body; } - const cachedBlob = await this.fileCache?.get(cacheKey); - let res: Response; - if (cachedBlob) res = new Response(cachedBlob); - else { - res = await downloadClient.downloadFileStream(file); - // We don't have a files cache currently, so this was already a - // no-op. But even if we had a cache, this seems sus, because - // res.blob() will read the stream and I'd think then trying to do - // the subsequent read of the stream again below won't work. - - // this.fileCache?.put(cacheKey, await res.blob()); - } + const res = await downloadClient.downloadFileStream(file); const body = res.body; if (!body) return null; const reader = body.getReader(); diff --git a/web/packages/new/photos/services/ml/blob.ts b/web/packages/new/photos/services/ml/blob.ts index 56fd258c06..2bf7b024ab 100644 --- a/web/packages/new/photos/services/ml/blob.ts +++ b/web/packages/new/photos/services/ml/blob.ts @@ -91,7 +91,7 @@ const fetchRenderableUploadItemBlob = async ( ) => { const fileType = file.metadata.fileType; if (fileType == FileType.video) { - const thumbnailData = await DownloadManager.getThumbnail(file); + const thumbnailData = await DownloadManager.thumbnailData(file); return new Blob([thumbnailData!]); } else { const blob = await readNonVideoUploadItem(uploadItem, electron); @@ -146,24 +146,20 @@ export const fetchRenderableEnteFileBlob = async ( ): Promise => { const fileType = file.metadata.fileType; if (fileType == FileType.video) { - const thumbnailData = await DownloadManager.getThumbnail(file); + const thumbnailData = await DownloadManager.thumbnailData(file); return new Blob([thumbnailData!]); } - const fileStream = await DownloadManager.getFile(file); - const originalImageBlob = await new Response(fileStream).blob(); + const originalFileBlob = await DownloadManager.fileBlob(file); if (fileType == FileType.livePhoto) { const { imageFileName, imageData } = await decodeLivePhoto( file.metadata.title, - originalImageBlob, + originalFileBlob, ); return renderableImageBlob(imageFileName, new Blob([imageData])); } else if (fileType == FileType.image) { - return await renderableImageBlob( - file.metadata.title, - originalImageBlob, - ); + return await renderableImageBlob(file.metadata.title, originalFileBlob); } else { // A layer above us should've already filtered these out. throw new Error(`Cannot index unsupported file type ${fileType}`);