Rework the original downloads
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<number, Promise<string | undefined>>();
|
||||
private fileObjectURLPromises = new Map<number, Promise<SourceURLs>>();
|
||||
private thumbnailURLPromises = new Map<
|
||||
number,
|
||||
Promise<string | undefined>
|
||||
>();
|
||||
/**
|
||||
* 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<number, Promise<string>>();
|
||||
private fileConversionPromises = new Map<number, Promise<SourceURLs>>();
|
||||
|
||||
private fileDownloadProgress = new Map<number, number>();
|
||||
@@ -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<string | undefined> {
|
||||
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<string | undefined> {
|
||||
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<ReadableStream<Uint8Array> | null> {
|
||||
this.ensureInitialized();
|
||||
try {
|
||||
const getFilePromise = async (): Promise<SourceURLs> => {
|
||||
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(
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user