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/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/services/download.ts b/web/packages/new/photos/services/download.ts index 2a8e7ba1fe..72db5eee89 100644 --- a/web/packages/new/photos/services/download.ts +++ b/web/packages/new/photos/services/download.ts @@ -90,8 +90,21 @@ class DownloadManagerImpl { * 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 thumbnailURLs = new Map>(); - private fileObjectURLPromises = new Map>(); + 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(); @@ -127,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.thumbnailURLs.clear(); this.fileDownloadProgress.clear(); this.progressUpdater = () => {}; } @@ -143,13 +156,43 @@ class DownloadManagerImpl { this.progressUpdater = progressUpdater; } - private downloadThumb = async (file: EnteFile) => { - const { downloadClient } = this.ensureInitialized(); + /** + * 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(); - const encryptedData = await downloadClient.downloadThumbnail(file); - const decryptionHeader = file.thumbnail.decryptionHeader; - return decryptThumbnail({ encryptedData, decryptionHeader }, file.key); - }; + if (!this.thumbnailURLPromises.has(file.id)) { + const url = this.thumbnailBytes(file, cachedOnly).then((bytes) => + bytes ? URL.createObjectURL(new Blob([bytes])) : 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. @@ -178,43 +221,13 @@ class DownloadManagerImpl { return thumb; } - /** - * 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(); + private downloadThumb = async (file: EnteFile) => { + const { downloadClient } = this.ensureInitialized(); - if (!this.thumbnailURLs.has(file.id)) { - const url = this.thumbnailBytes(file, cachedOnly).then((bytes) => - bytes ? URL.createObjectURL(new Blob([bytes])) : undefined, - ); - this.thumbnailURLs.set(file.id, url); - } - - let thumb = await this.thumbnailURLs.get(file.id); - if (cachedOnly) return thumb; - - if (!thumb) { - this.thumbnailURLs.delete(file.id); - thumb = await this.renderableThumbnailURL(file); - } - return thumb; - } + const encryptedData = await downloadClient.downloadThumbnail(file); + const decryptionHeader = file.thumbnail.decryptionHeader; + return decryptThumbnail({ encryptedData, decryptionHeader }, file.key); + }; /** * The `forceConvert` option is true when the user presses the "Convert" @@ -228,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; @@ -260,43 +268,73 @@ 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. + * + * @returns + */ + async fileStream( file: EnteFile, - cacheInMemory = false, ): Promise | null> { this.ensureInitialized(); - 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()); + + const cached = await this.fileStreamFromCachedURL(file); + if (cached) return cached; + + return this.downloadFile(file); + } + + private async fileStreamFromCachedURL(file: EnteFile) { + 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); + throw e; } - // 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); - } - } catch (e) { - this.fileObjectURLPromises.delete(file.id); - log.error("download manager getFile Failed", e); - throw e; } + return null; + } + + /** + * A private variant of {@link fileStream} that also caches the results. + */ + private async fileURLDownloadAndCacheIfNeeded(file: EnteFile) { + this.ensureInitialized(); + + if (!this.fileURLPromises.has(file.id)) { + this.fileURLPromises.set( + file.id, + (async () => { + const fileStream = await this.downloadFile(file); + const fileBlob = await new Response(fileStream).blob(); + return URL.createObjectURL(fileBlob); + })(), + ); + } + + return this.fileURLPromises.get(file.id); } private async downloadFile( diff --git a/web/packages/new/photos/services/ml/blob.ts b/web/packages/new/photos/services/ml/blob.ts index e296dce73a..aa7221982d 100644 --- a/web/packages/new/photos/services/ml/blob.ts +++ b/web/packages/new/photos/services/ml/blob.ts @@ -150,20 +150,16 @@ export const fetchRenderableEnteFileBlob = async ( 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}`);