[desktop] Fetch face indexes - Part 4/x (#2341)

This mostly moves a few files that are transitively used by f-index.ts
to new (so that we can move f-index.ts to new and call it from the
worker therein).
This commit is contained in:
Manav Rathi
2024-07-02 12:28:11 +05:30
committed by GitHub
65 changed files with 723 additions and 641 deletions

View File

@@ -243,12 +243,14 @@ const decryptEnteFile = async (
file.metadata.title = file.pubMagicMetadata.data.editedName;
}
// @ts-expect-error TODO: The core types need to be updated to allow the
// possibility of missing metadata fiels.
// possibility of missing metadata fields.
return file;
};
const isFileEligible = (file: EnteFile) => {
if (!isImageOrLivePhoto(file)) return false;
// @ts-expect-error TODO: The core types need to be updated to allow the
// possibility of missing info fields (or do they?)
if (file.info.fileSize > 100 * 1024 * 1024) return false;
// This check is fast but potentially incorrect because in practice we do

View File

@@ -1,10 +1,10 @@
import downloadManager from "@/new/photos/services/download";
import { EnteFile } from "@/new/photos/types/file";
import {
LoadingThumbnail,
StaticThumbnail,
} from "components/PlaceholderThumbnails";
import { useEffect, useState } from "react";
import downloadManager from "services/download";
export default function CollectionCard(props: {
children?: any;

View File

@@ -1,4 +1,5 @@
import { FILE_TYPE } from "@/media/file-type";
import DownloadManager from "@/new/photos/services/download";
import type { LivePhotoSourceURL, SourceURLs } from "@/new/photos/types/file";
import { EnteFile } from "@/new/photos/types/file";
import log from "@/next/log";
@@ -14,7 +15,6 @@ import PhotoSwipe from "photoswipe";
import { useContext, useEffect, useState } from "react";
import AutoSizer from "react-virtualized-auto-sizer";
import { Duplicate } from "services/deduplicationService";
import DownloadManager from "services/download";
import {
SelectedState,
SetFilesDownloadProgressAttributesCreator,

View File

@@ -1,4 +1,5 @@
import { EnteFile } from "@/new/photos/types/file";
import { formattedByteSize } from "@/new/photos/utils/units";
import { FlexWrapper } from "@ente/shared/components/Container";
import { Box, styled } from "@mui/material";
import {
@@ -19,7 +20,6 @@ import {
areEqual,
} from "react-window";
import { Duplicate } from "services/deduplicationService";
import { formattedByteSize } from "utils/units";
export enum ITEM_TYPE {
TIME = "TIME",

View File

@@ -1,4 +1,5 @@
import { EnteFile } from "@/new/photos/types/file";
import { formattedByteSize } from "@/new/photos/utils/units";
import { FlexWrapper } from "@ente/shared/components/Container";
import { formatDate } from "@ente/shared/time/format";
import { Box, Checkbox, Link, Typography, styled } from "@mui/material";
@@ -24,7 +25,6 @@ import {
} from "react-window";
import { handleSelectCreator } from "utils/photoFrame";
import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery";
import { formattedByteSize } from "utils/units";
const FOOTER_HEIGHT = 90;
const ALBUM_FOOTER_HEIGHT = 75;

View File

@@ -1,5 +1,6 @@
import { FILE_TYPE } from "@/media/file-type";
import { EnteFile } from "@/new/photos/types/file";
import { formattedByteSize } from "@/new/photos/utils/units";
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
import { FlexWrapper } from "@ente/shared/components/Container";
@@ -8,7 +9,6 @@ import VideocamOutlined from "@mui/icons-material/VideocamOutlined";
import Box from "@mui/material/Box";
import { useEffect, useState } from "react";
import { changeFileName, updateExistingFilePubMetadata } from "utils/file";
import { formattedByteSize } from "utils/units";
import { FileNameEditDialog } from "./FileNameEditDialog";
import InfoItem from "./InfoItem";

View File

@@ -1,3 +1,4 @@
import downloadManager from "@/new/photos/services/download";
import { EnteFile } from "@/new/photos/types/file";
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
@@ -35,7 +36,6 @@ import { AppContext } from "pages/_app";
import type { Dispatch, MutableRefObject, SetStateAction } from "react";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { getLocalCollections } from "services/collectionService";
import downloadManager from "services/download";
import uploadManager from "services/upload/uploadManager";
import { getEditorCloseConfirmationMessage } from "utils/ui";
import ColoursMenu from "./ColoursMenu";

View File

@@ -11,12 +11,14 @@ import {
copyFileToClipboard,
downloadSingleFile,
getFileFromURL,
isSupportedRawFormat,
} from "utils/file";
import { FILE_TYPE } from "@/media/file-type";
import { isNonWebImageFileExtension } from "@/media/formats";
import downloadManager from "@/new/photos/services/download";
import type { LoadedLivePhotoSourceURL } from "@/new/photos/types/file";
import { detectFileTypeInfo } from "@/new/photos/utils/detect-type";
import { isNativeConvertibleToJPEG } from "@/new/photos/utils/file";
import { lowercaseExtension } from "@/next/file";
import { FlexWrapper } from "@ente/shared/components/Container";
import EnteSpinner from "@ente/shared/components/EnteSpinner";
@@ -44,8 +46,6 @@ import { t } from "i18next";
import isElectron from "is-electron";
import { AppContext } from "pages/_app";
import { GalleryContext } from "pages/gallery";
import { detectFileTypeInfo } from "services/detect-type";
import downloadManager from "services/download";
import { getParsedExifData } from "services/exif";
import { trashFiles } from "services/fileService";
import { SetFilesDownloadProgressAttributesCreator } from "types/gallery";
@@ -352,7 +352,9 @@ function PhotoViewer(props: Iprops) {
const extension = lowercaseExtension(file.metadata.title);
const isSupported =
!isNonWebImageFileExtension(extension) ||
isSupportedRawFormat(extension);
// TODO: This condition doesn't sound correct when running in the
// web app?
isNativeConvertibleToJPEG(extension);
setShowEditButton(
file.metadata.fileType === FILE_TYPE.IMAGE && isSupported,
);

View File

@@ -1,7 +1,7 @@
import { formattedStorageByteSize } from "@/new/photos/utils/units";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { Box, Typography } from "@mui/material";
import { t } from "i18next";
import { formattedStorageByteSize } from "utils/units";
import { Progressbar } from "../../styledComponents";

View File

@@ -1,6 +1,6 @@
import { Box, styled, Typography } from "@mui/material";
import { bytesInGB, formattedStorageByteSize } from "@/new/photos/utils/units";
import { Box, Typography, styled } from "@mui/material";
import { t } from "i18next";
import { bytesInGB, formattedStorageByteSize } from "utils/units";
const MobileSmallBox = styled(Box)`
display: none;

View File

@@ -1,8 +1,11 @@
import { Dialog, DialogContent, Link } from "@mui/material";
import { t } from "i18next";
import {
UPLOAD_RESULT,
UPLOAD_STAGES,
} from "@/new/photos/services/upload/types";
import { dialogCloseHandler } from "@ente/shared/components/DialogBox/TitleWithCloseButton";
import { UPLOAD_RESULT, UPLOAD_STAGES } from "constants/upload";
import UploadProgressContext from "contexts/uploadProgress";
import { useContext, useEffect, useState } from "react";
import { Trans } from "react-i18next";

View File

@@ -1,5 +1,8 @@
import {
UPLOAD_RESULT,
UPLOAD_STAGES,
} from "@/new/photos/services/upload/types";
import { Button, DialogActions } from "@mui/material";
import { UPLOAD_RESULT, UPLOAD_STAGES } from "constants/upload";
import { t } from "i18next";
import { useContext } from "react";

View File

@@ -11,8 +11,8 @@ import {
} from "./section";
import { InProgressItemContainer } from "./styledComponents";
import { UPLOAD_STAGES } from "@/new/photos/services/upload/types";
import { CaptionedText } from "components/CaptionedText";
import { UPLOAD_STAGES } from "constants/upload";
export const InProgressSection = () => {
const { inProgressUploads, hasLivePhotos, uploadFileNames, uploadStage } =

View File

@@ -1,4 +1,4 @@
import { UPLOAD_STAGES } from "constants/upload";
import { UPLOAD_STAGES } from "@/new/photos/services/upload/types";
import UploadProgressContext from "contexts/uploadProgress";
import { t } from "i18next";
import { AppContext } from "pages/_app";

View File

@@ -1,5 +1,5 @@
import { UPLOAD_STAGES } from "@/new/photos/services/upload/types";
import { Box, Divider, LinearProgress } from "@mui/material";
import { UPLOAD_STAGES } from "constants/upload";
import UploadProgressContext from "contexts/uploadProgress";
import { useContext } from "react";

View File

@@ -1,7 +1,7 @@
import { UPLOAD_RESULT } from "@/new/photos/services/upload/types";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { CaptionedText } from "components/CaptionedText";
import ItemList from "components/ItemList";
import { UPLOAD_RESULT } from "constants/upload";
import UploadProgressContext from "contexts/uploadProgress";
import { useContext } from "react";
import {

View File

@@ -1,10 +1,10 @@
import { UPLOAD_STAGES } from "@/new/photos/services/upload/types";
import {
IconButtonWithBG,
SpaceBetweenFlex,
} from "@ente/shared/components/Container";
import Close from "@mui/icons-material/Close";
import { Box, DialogTitle, Stack, Typography } from "@mui/material";
import { UPLOAD_STAGES } from "constants/upload";
import { t } from "i18next";
import { useContext } from "react";

View File

@@ -1,3 +1,9 @@
import { exportMetadataDirectoryName } from "@/new/photos/services/export";
import type {
FileAndPath,
UploadItem,
} from "@/new/photos/services/upload/types";
import { UPLOAD_STAGES } from "@/new/photos/services/upload/types";
import { basename } from "@/next/file";
import log from "@/next/log";
import type { CollectionMapping, Electron, ZipItem } from "@/next/types/ipc";
@@ -7,7 +13,6 @@ import { CustomError } from "@ente/shared/error";
import { isPromise } from "@ente/shared/utils";
import DiscFullIcon from "@mui/icons-material/DiscFull";
import UserNameInputDialog from "components/UserNameInputDialog";
import { UPLOAD_STAGES } from "constants/upload";
import { t } from "i18next";
import isElectron from "is-electron";
import { AppContext } from "pages/_app";
@@ -15,13 +20,11 @@ import { GalleryContext } from "pages/gallery";
import { useContext, useEffect, useRef, useState } from "react";
import billingService from "services/billingService";
import { getLatestCollections } from "services/collectionService";
import { exportMetadataDirectoryName } from "services/export";
import {
getPublicCollectionUID,
getPublicCollectionUploaderName,
savePublicCollectionUploaderName,
} from "services/publicCollectionService";
import type { FileAndPath, UploadItem } from "services/upload/types";
import type {
InProgressUpload,
SegregatedFinishedUploads,

View File

@@ -1,3 +1,4 @@
import { bytesInGB } from "@/new/photos/utils/units";
import log from "@/next/log";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { SUPPORT_EMAIL } from "@ente/shared/constants/urls";
@@ -27,7 +28,6 @@ import {
planForSubscription,
updateSubscription,
} from "utils/billing";
import { bytesInGB } from "utils/units";
import { getLocalUserDetails } from "utils/user";
import { getTotalFamilyUsage, isPartOfFamily } from "utils/user/family";
import { ManageSubscription } from "./manageSubscription";

View File

@@ -1,8 +1,8 @@
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { Box, styled, Typography } from "@mui/material";
import { formattedStorageByteSize } from "@/new/photos/utils/units";
import { Trans } from "react-i18next";
import { formattedStorageByteSize } from "utils/units";
const RowContainer = styled(SpaceBetweenFlex)(({ theme }) => ({
// gap: theme.spacing(1.5),

View File

@@ -1,3 +1,4 @@
import { formattedStorageByteSize } from "@/new/photos/utils/units";
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import ArrowForward from "@mui/icons-material/ArrowForward";
import { Box, IconButton, Stack, Typography, styled } from "@mui/material";
@@ -12,7 +13,6 @@ import {
isPopularPlan,
isUserSubscribedPlan,
} from "utils/billing";
import { formattedStorageByteSize } from "utils/units";
import { PlanRow } from "./planRow";
interface Iprops {

View File

@@ -1,3 +1,4 @@
import { bytesInGB } from "@/new/photos/utils/units";
import { FlexWrapper, FluidContainer } from "@ente/shared/components/Container";
import ArrowForward from "@mui/icons-material/ArrowForward";
import Done from "@mui/icons-material/Done";
@@ -7,7 +8,6 @@ import { PLAN_PERIOD } from "constants/gallery";
import { t } from "i18next";
import { Plan, Subscription } from "types/billing";
import { hasPaidSubscription, isUserSubscribedPlan } from "utils/billing";
import { bytesInGB } from "utils/units";
interface Iprops {
plan: Plan;

View File

@@ -1,4 +1,5 @@
import { FILE_TYPE } from "@/media/file-type";
import DownloadManager from "@/new/photos/services/download";
import { EnteFile } from "@/new/photos/types/file";
import log from "@/next/log";
import { Overlay } from "@ente/shared/components/Container";
@@ -17,7 +18,6 @@ import i18n from "i18next";
import { DeduplicateContext } from "pages/deduplicate";
import { GalleryContext } from "pages/gallery";
import React, { useContext, useEffect, useRef, useState } from "react";
import DownloadManager from "services/download";
import { shouldShowAvatar } from "utils/file";
import Avatar from "./Avatar";

View File

@@ -1,26 +0,0 @@
import { Location } from "types/metadata";
export const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random();
export const NULL_LOCATION: Location = { latitude: null, longitude: null };
export enum UPLOAD_STAGES {
START,
READING_GOOGLE_METADATA_FILES,
EXTRACTING_METADATA,
UPLOADING,
CANCELLING,
FINISH,
}
export enum UPLOAD_RESULT {
FAILED,
ALREADY_UPLOADED,
UNSUPPORTED,
BLOCKED,
TOO_LARGE,
LARGER_THAN_AVAILABLE_STORAGE,
UPLOADED,
UPLOADED_WITH_STATIC_THUMBNAIL,
ADDED_SYMLINK,
}

View File

@@ -1,4 +1,4 @@
import { UPLOAD_STAGES } from "constants/upload";
import { UPLOAD_STAGES } from "@/new/photos/services/upload/types";
import { createContext } from "react";
import type {
InProgressUpload,

View File

@@ -1,3 +1,4 @@
import DownloadManager from "@/new/photos/services/download";
import { clientPackageName, staticAppTitle } from "@/next/app";
import { CustomHead } from "@/next/components/Head";
import { setupI18n } from "@/next/i18n";
@@ -48,7 +49,6 @@ import { useRouter } from "next/router";
import "photoswipe/dist/photoswipe.css";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import LoadingBar from "react-top-loading-bar";
import DownloadManager from "services/download";
import { resumeExportsIfNeeded } from "services/export";
import {
isFaceIndexingEnabled,

View File

@@ -1,5 +1,6 @@
import { WhatsNew } from "@/new/photos/components/WhatsNew";
import { shouldShowWhatsNew } from "@/new/photos/services/changelog";
import downloadManager from "@/new/photos/services/download";
import {
getLocalFiles,
getLocalTrashedFiles,
@@ -92,7 +93,6 @@ import {
getHiddenItemsSummary,
getSectionSummaries,
} from "services/collectionService";
import downloadManager from "services/download";
import { syncFiles } from "services/fileService";
import locationSearchService from "services/locationSearchService";
import { sync } from "services/sync";

View File

@@ -1,3 +1,4 @@
import downloadManager from "@/new/photos/services/download";
import { EnteFile } from "@/new/photos/types/file";
import { mergeMetadata } from "@/new/photos/utils/file";
import log from "@/next/log";
@@ -45,7 +46,6 @@ import { useRouter } from "next/router";
import { AppContext } from "pages/_app";
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { useDropzone } from "react-dropzone";
import downloadManager from "services/download";
import {
getLocalPublicCollection,
getLocalPublicCollectionPassword,

View File

@@ -1,4 +1,5 @@
import { FILE_TYPE } from "@/media/file-type";
import downloadManager from "@/new/photos/services/download";
import { getAllLocalFiles, getLocalFiles } from "@/new/photos/services/files";
import { EnteFile } from "@/new/photos/types/file";
import { ensureElectron } from "@/next/electron";
@@ -10,7 +11,6 @@ import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
import PQueue from "p-queue";
import { Embedding } from "types/embedding";
import { getPersonalFiles } from "utils/file";
import downloadManager from "./download";
import { localCLIPEmbeddings, putEmbedding } from "./embeddingService";
/** Status of CLIP indexing on the images in the user's local library. */

View File

@@ -1,149 +0,0 @@
import { EnteFile } from "@/new/photos/types/file";
import { customAPIOrigin } from "@/next/origins";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { retryAsyncFunction } from "@ente/shared/utils";
import { DownloadClient } from "services/download";
export class PhotosDownloadClient implements DownloadClient {
constructor(
private token: string,
private timeout: number,
) {}
updateTokens(token: string) {
this.token = token;
}
async downloadThumbnail(file: EnteFile): Promise<Uint8Array> {
const token = this.token;
if (!token) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getThumbnail = () => {
const opts = { responseType: "arraybuffer", timeout: this.timeout };
if (customOrigin) {
const params = new URLSearchParams({ token });
return HTTPService.get(
`${customOrigin}/files/preview/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://thumbnails.ente.io/?fileID=${file.id}`,
undefined,
{ "X-Auth-Token": token },
opts,
);
}
};
const resp = await retryAsyncFunction(getThumbnail);
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
return new Uint8Array(resp.data);
}
async downloadFile(
file: EnteFile,
onDownloadProgress: (event: { loaded: number; total: number }) => void,
): Promise<Uint8Array> {
const token = this.token;
if (!token) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getFile = () => {
const opts = {
responseType: "arraybuffer",
timeout: this.timeout,
onDownloadProgress,
};
if (customOrigin) {
const params = new URLSearchParams({ token });
return HTTPService.get(
`${customOrigin}/files/download/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://files.ente.io/?fileID=${file.id}`,
undefined,
{ "X-Auth-Token": token },
opts,
);
}
};
const resp = await retryAsyncFunction(getFile);
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
return new Uint8Array(resp.data);
}
async downloadFileStream(file: EnteFile): Promise<Response> {
const token = this.token;
if (!token) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// [Note: Passing credentials for self-hosted file fetches]
//
// Fetching files (or thumbnails) in the default self-hosted Ente
// configuration involves a redirection:
//
// 1. The browser makes a HTTP GET to a museum with credentials. Museum
// inspects the credentials, in this case the auth token, and if
// they're valid, returns a HTTP 307 redirect to the pre-signed S3
// URL that to the file in the configured S3 bucket.
//
// 2. The browser follows the redirect to get the actual file. The URL
// is pre-signed, i.e. already has all credentials needed to prove to
// the S3 object storage that it should serve this response.
//
// For the first step normally we'd pass the auth the token via the
// "X-Auth-Token" HTTP header. In this case though, that would be
// problematic because the browser preserves the request headers when it
// follows the HTTP 307 redirect, and the "X-Auth-Token" header also
// gets sent to the redirected S3 request made in second step.
//
// To avoid this, we pass the token as a query parameter. Generally this
// is not a good idea, but in this case (a) the URL is not a user
// visible one and (b) even if it gets logged, it'll be in the
// self-hosters own service.
//
// Note that Ente's own servers don't have these concerns because we use
// a slightly different flow involving a proxy instead of directly
// connecting to the S3 storage.
//
// 1. The web browser makes a HTTP GET request to a proxy passing it the
// credentials in the "X-Auth-Token".
//
// 2. The proxy then does both the original steps: (a). Use the
// credentials to get the pre signed URL, and (b) fetch that pre
// signed URL and stream back the response.
const getFile = () => {
if (customOrigin) {
const params = new URLSearchParams({ token });
return fetch(
`${customOrigin}/files/download/${file.id}?${params.toString()}`,
);
} else {
return fetch(`https://files.ente.io/?fileID=${file.id}`, {
headers: {
"X-Auth-Token": token,
},
});
}
};
return retryAsyncFunction(getFile);
}
}

View File

@@ -1,145 +0,0 @@
import { EnteFile } from "@/new/photos/types/file";
import { customAPIOrigin } from "@/next/origins";
import { CustomError } from "@ente/shared/error";
import HTTPService from "@ente/shared/network/HTTPService";
import { retryAsyncFunction } from "@ente/shared/utils";
import { DownloadClient } from "services/download";
export class PublicAlbumsDownloadClient implements DownloadClient {
private token: string;
private passwordToken: string;
constructor(private timeout: number) {}
updateTokens(token: string, passwordToken: string) {
this.token = token;
this.passwordToken = passwordToken;
}
downloadThumbnail = async (file: EnteFile) => {
const accessToken = this.token;
const accessTokenJWT = this.passwordToken;
if (!accessToken) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getThumbnail = () => {
const opts = {
responseType: "arraybuffer",
};
if (customOrigin) {
const params = new URLSearchParams({
accessToken,
...(accessTokenJWT && { accessTokenJWT }),
});
return HTTPService.get(
`${customOrigin}/public-collection/files/preview/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://public-albums.ente.io/preview/?fileID=${file.id}`,
undefined,
{
"X-Auth-Access-Token": accessToken,
...(accessTokenJWT && {
"X-Auth-Access-Token-JWT": accessTokenJWT,
}),
},
opts,
);
}
};
const resp = await getThumbnail();
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
return new Uint8Array(resp.data);
};
downloadFile = async (
file: EnteFile,
onDownloadProgress: (event: { loaded: number; total: number }) => void,
) => {
const accessToken = this.token;
const accessTokenJWT = this.passwordToken;
if (!accessToken) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getFile = () => {
const opts = {
responseType: "arraybuffer",
timeout: this.timeout,
onDownloadProgress,
};
if (customOrigin) {
const params = new URLSearchParams({
accessToken,
...(accessTokenJWT && { accessTokenJWT }),
});
return HTTPService.get(
`${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://public-albums.ente.io/download/?fileID=${file.id}`,
undefined,
{
"X-Auth-Access-Token": accessToken,
...(accessTokenJWT && {
"X-Auth-Access-Token-JWT": accessTokenJWT,
}),
},
opts,
);
}
};
const resp = await retryAsyncFunction(getFile);
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
return new Uint8Array(resp.data);
};
async downloadFileStream(file: EnteFile): Promise<Response> {
const accessToken = this.token;
const accessTokenJWT = this.passwordToken;
if (!accessToken) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getFile = () => {
if (customOrigin) {
const params = new URLSearchParams({
accessToken,
...(accessTokenJWT && { accessTokenJWT }),
});
return fetch(
`${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`,
);
} else {
return fetch(
`https://public-albums.ente.io/download/?fileID=${file.id}`,
{
headers: {
"X-Auth-Access-Token": accessToken,
...(accessTokenJWT && {
"X-Auth-Access-Token-JWT": accessTokenJWT,
}),
},
},
);
}
};
return retryAsyncFunction(getFile);
}
}

View File

@@ -1,10 +1,13 @@
import { type FileTypeInfo } from "@/media/file-type";
import { NULL_LOCATION } from "@/new/photos/services/upload/types";
import type {
Location,
ParsedExtractedMetadata,
} from "@/new/photos/types/metadata";
import log from "@/next/log";
import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time";
import { NULL_LOCATION } from "constants/upload";
import exifr from "exifr";
import piexif from "piexifjs";
import type { Location, ParsedExtractedMetadata } from "types/metadata";
type ParsedEXIFData = Record<string, any> &
Partial<{

View File

@@ -1,9 +1,16 @@
import { FILE_TYPE } from "@/media/file-type";
import { decodeLivePhoto } from "@/media/live-photo";
import type { Metadata } from "@/media/types/file";
import downloadManager from "@/new/photos/services/download";
import {
exportMetadataDirectoryName,
exportTrashDirectoryName,
} from "@/new/photos/services/export";
import { getAllLocalFiles } from "@/new/photos/services/files";
import { EnteFile } from "@/new/photos/types/file";
import { mergeMetadata } from "@/new/photos/utils/file";
import { safeDirectoryName, safeFileName } from "@/new/photos/utils/native-fs";
import { writeStream } from "@/new/photos/utils/native-stream";
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { wait } from "@/utils/promise";
@@ -31,10 +38,7 @@ import {
getNonEmptyPersonalCollections,
} from "utils/collection";
import { getPersonalFiles, getUpdatedEXIFFileForDownload } from "utils/file";
import { safeDirectoryName, safeFileName } from "utils/native-fs";
import { writeStream } from "utils/native-stream";
import { getAllLocalCollections } from "../collectionService";
import downloadManager from "../download";
import { migrateExport } from "./migration";
/** Name of the JSON file in which we keep the state of the export. */
@@ -46,18 +50,6 @@ const exportRecordFileName = "export_status.json";
*/
const exportDirectoryName = "Ente Photos";
/**
* Name of the directory in which we put our metadata when exporting to the file
* system.
*/
export const exportMetadataDirectoryName = "metadata";
/**
* Name of the directory in which we keep trash items when deleting files that
* have been exported to the local disk previously.
*/
export const exportTrashDirectoryName = "Trash";
export enum ExportStage {
INIT = 0,
MIGRATION = 1,

View File

@@ -1,8 +1,15 @@
import { FILE_TYPE } from "@/media/file-type";
import { decodeLivePhoto } from "@/media/live-photo";
import downloadManager from "@/new/photos/services/download";
import { exportMetadataDirectoryName } from "@/new/photos/services/export";
import { getAllLocalFiles } from "@/new/photos/services/files";
import { EnteFile } from "@/new/photos/types/file";
import { mergeMetadata } from "@/new/photos/utils/file";
import {
safeDirectoryName,
safeFileName,
sanitizeFilename,
} from "@/new/photos/utils/native-fs";
import { ensureElectron } from "@/next/electron";
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
@@ -10,7 +17,6 @@ import { wait } from "@/utils/promise";
import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
import type { User } from "@ente/shared/user/types";
import { getLocalCollections } from "services/collectionService";
import downloadManager from "services/download";
import { Collection } from "types/collection";
import {
CollectionExportNames,
@@ -25,12 +31,6 @@ import {
import { getNonEmptyPersonalCollections } from "utils/collection";
import { getIDBasedSortedFiles, getPersonalFiles } from "utils/file";
import {
safeDirectoryName,
safeFileName,
sanitizeFilename,
} from "utils/native-fs";
import {
exportMetadataDirectoryName,
getCollectionIDFromFileUID,
getExportRecordFileUID,
getLivePhotoExportName,

View File

@@ -1,5 +1,6 @@
import { FILE_TYPE } from "@/media/file-type";
import { decodeLivePhoto } from "@/media/live-photo";
import DownloadManager from "@/new/photos/services/download";
import type {
Box,
Dimensions,
@@ -8,10 +9,10 @@ import type {
} from "@/new/photos/services/face/types";
import { faceIndexingVersion } from "@/new/photos/services/face/types";
import type { EnteFile } from "@/new/photos/types/file";
import { getRenderableImage } from "@/new/photos/utils/file";
import log from "@/next/log";
import { workerBridge } from "@/next/worker/worker-bridge";
import { Matrix } from "ml-matrix";
import DownloadManager from "services/download";
import { getSimilarityTransformation } from "similarity-transformation";
import {
Matrix as TransformationMatrix,
@@ -20,7 +21,6 @@ import {
scale,
translate,
} from "transformation-matrix";
import { getRenderableImage } from "utils/file";
import { saveFaceCrop } from "./crop";
import {
clamp,

View File

@@ -1,6 +1,6 @@
import downloadManager from "@/new/photos/services/download";
import { EnteFile } from "@/new/photos/types/file";
import { expose } from "comlink";
import downloadManager from "services/download";
import mlService from "services/machineLearning/machineLearningService";
export class DedicatedMLWorker {

View File

@@ -1,14 +1,14 @@
import { FILE_TYPE } from "@/media/file-type";
import downloadManager from "@/new/photos/services/download";
import { EnteFile } from "@/new/photos/types/file";
import { detectFileTypeInfo } from "@/new/photos/utils/detect-type";
import log from "@/next/log";
import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time";
import type { FixOption } from "components/FixCreationTime";
import { detectFileTypeInfo } from "services/detect-type";
import {
changeFileCreationTime,
updateExistingFilePubMetadata,
} from "utils/file";
import downloadManager from "./download";
import { getParsedExifData } from "./exif";
const EXIF_TIME_TAGS = [

View File

@@ -1,6 +1,6 @@
import type { Location } from "@/new/photos/types/metadata";
import log from "@/next/log";
import { LocationTagData } from "types/entity";
import { Location } from "types/metadata";
import type { LocationTagData } from "types/entity";
export interface City {
city: string;

View File

@@ -1,10 +1,10 @@
import DownloadManager from "@/new/photos/services/download";
import { terminateFaceWorker } from "@/new/photos/services/face";
import { clearFaceData } from "@/new/photos/services/face/db";
import { clearFeatureFlagSessionState } from "@/new/photos/services/feature-flags";
import log from "@/next/log";
import { accountLogout } from "@ente/accounts/services/logout";
import { clipService } from "services/clip-service";
import DownloadManager from "./download";
import exportService from "./export";
import mlWorkManager from "./face/mlWorkManager";
@@ -31,7 +31,7 @@ export const photosLogout = async () => {
}
try {
await DownloadManager.logout();
DownloadManager.logout();
} catch (e) {
ignoreError("download", e);
}

View File

@@ -1,12 +1,12 @@
/** @file Dealing with the JSON metadata in Google Takeouts */
import type { UploadItem } from "@/new/photos/services/upload/types";
import { NULL_LOCATION } from "@/new/photos/services/upload/types";
import type { Location } from "@/new/photos/types/metadata";
import { readStream } from "@/new/photos/utils/native-stream";
import { ensureElectron } from "@/next/electron";
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
import { NULL_LOCATION } from "constants/upload";
import type { Location } from "types/metadata";
import { readStream } from "utils/native-stream";
import type { UploadItem } from "./types";
export interface ParsedMetadataJSON {
creationTime: number;

View File

@@ -1,12 +1,15 @@
import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type";
import { heicToJPEG } from "@/media/heic-convert";
import { scaledImageDimensions } from "@/media/image";
import * as ffmpeg from "@/new/photos/services/ffmpeg";
import {
toDataOrPathOrZipEntry,
type DesktopUploadItem,
} from "@/new/photos/services/upload/types";
import log from "@/next/log";
import { type Electron } from "@/next/types/ipc";
import { ensure } from "@/utils/ensure";
import { withTimeout } from "@/utils/promise";
import * as ffmpeg from "services/ffmpeg";
import { toDataOrPathOrZipEntry, type DesktopUploadItem } from "./types";
/** Maximum width or height of the generated thumbnail */
const maxThumbnailDimension = 720;

View File

@@ -1,6 +1,12 @@
import { FILE_TYPE } from "@/media/file-type";
import { potentialFileTypeFromExtension } from "@/media/live-photo";
import { getLocalFiles } from "@/new/photos/services/files";
import type { UploadItem } from "@/new/photos/services/upload/types";
import {
RANDOM_PERCENTAGE_PROGRESS_FOR_PUT,
UPLOAD_RESULT,
UPLOAD_STAGES,
} from "@/new/photos/services/upload/types";
import { EncryptedEnteFile, EnteFile } from "@/new/photos/types/file";
import { ensureElectron } from "@/next/electron";
import { lowercaseExtension, nameAndExtension } from "@/next/file";
@@ -15,11 +21,6 @@ import { CustomError } from "@ente/shared/error";
import { Events, eventBus } from "@ente/shared/events";
import { Canceler } from "axios";
import type { Remote } from "comlink";
import {
RANDOM_PERCENTAGE_PROGRESS_FOR_PUT,
UPLOAD_RESULT,
UPLOAD_STAGES,
} from "constants/upload";
import isElectron from "is-electron";
import {
getLocalPublicFiles,
@@ -35,7 +36,6 @@ import {
tryParseTakeoutMetadataJSON,
type ParsedMetadataJSON,
} from "./takeout";
import type { UploadItem } from "./types";
import UploadService, { uploadItemFileName, uploader } from "./uploadService";
export type FileID = number;

View File

@@ -2,6 +2,13 @@ import { hasFileHash } from "@/media/file";
import { FILE_TYPE, type FileTypeInfo } from "@/media/file-type";
import { encodeLivePhoto } from "@/media/live-photo";
import type { Metadata } from "@/media/types/file";
import * as ffmpeg from "@/new/photos/services/ffmpeg";
import type { UploadItem } from "@/new/photos/services/upload/types";
import {
NULL_LOCATION,
RANDOM_PERCENTAGE_PROGRESS_FOR_PUT,
UPLOAD_RESULT,
} from "@/new/photos/services/upload/types";
import {
EnteFile,
MetadataFileAttributes,
@@ -11,6 +18,9 @@ import {
type FilePublicMagicMetadataProps,
} from "@/new/photos/types/file";
import { EncryptedMagicMetadata } from "@/new/photos/types/magicMetadata";
import type { ParsedExtractedMetadata } from "@/new/photos/types/metadata";
import { detectFileTypeInfoFromChunk } from "@/new/photos/utils/detect-type";
import { readStream } from "@/new/photos/utils/native-stream";
import { ensureElectron } from "@/next/electron";
import { basename } from "@/next/file";
import log from "@/next/log";
@@ -21,26 +31,17 @@ import type { B64EncryptionResult } from "@ente/shared/crypto/internal/libsodium
import { ENCRYPTION_CHUNK_SIZE } from "@ente/shared/crypto/internal/libsodium";
import { CustomError, handleUploadError } from "@ente/shared/error";
import type { Remote } from "comlink";
import {
NULL_LOCATION,
RANDOM_PERCENTAGE_PROGRESS_FOR_PUT,
UPLOAD_RESULT,
} from "constants/upload";
import { addToCollection } from "services/collectionService";
import { parseImageMetadata } from "services/exif";
import * as ffmpeg from "services/ffmpeg";
import {
PublicUploadProps,
type LivePhotoAssets,
} from "services/upload/uploadManager";
import type { ParsedExtractedMetadata } from "types/metadata";
import {
getNonEmptyMagicMetadataProps,
updateMagicMetadata,
} from "utils/magicMetadata";
import { readStream } from "utils/native-stream";
import * as convert from "xml-js";
import { detectFileTypeInfoFromChunk } from "../detect-type";
import { tryParseEpochMicrosecondsFromFileName } from "./date";
import publicUploadHttpClient from "./publicUploadHttpClient";
import type { ParsedMetadataJSON } from "./takeout";
@@ -50,7 +51,6 @@ import {
generateThumbnailNative,
generateThumbnailWeb,
} from "./thumbnail";
import type { UploadItem } from "./types";
import UploadHttpClient from "./uploadHttpClient";
import type { UploadableUploadItem } from "./uploadManager";

View File

@@ -4,6 +4,7 @@
*/
import { getLocalFiles } from "@/new/photos/services/files";
import { UPLOAD_RESULT } from "@/new/photos/services/upload/types";
import { EncryptedEnteFile } from "@/new/photos/types/file";
import { ensureElectron } from "@/next/electron";
import { basename, dirname } from "@/next/file";
@@ -14,7 +15,6 @@ import type {
FolderWatchSyncedFile,
} from "@/next/types/ipc";
import { ensureString } from "@/utils/ensure";
import { UPLOAD_RESULT } from "constants/upload";
import debounce from "debounce";
import uploadManager, {
type UploadItemWithCollection,

View File

@@ -1,4 +1,4 @@
import { Location } from "types/metadata";
import { Location } from "@/new/photos/types/metadata";
export enum EntityType {
LOCATION_TAG = "location",

View File

@@ -1,11 +0,0 @@
export interface Location {
latitude: number;
longitude: number;
}
export interface ParsedExtractedMetadata {
location: Location;
creationTime: number;
width: number;
height: number;
}

View File

@@ -1,6 +1,7 @@
import { getAllLocalFiles, getLocalFiles } from "@/new/photos/services/files";
import { EnteFile } from "@/new/photos/types/file";
import { SUB_TYPE, VISIBILITY_STATE } from "@/new/photos/types/magicMetadata";
import { safeDirectoryName } from "@/new/photos/utils/native-fs";
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { CustomError } from "@ente/shared/error";
@@ -43,7 +44,6 @@ import {
import { SetFilesDownloadProgressAttributes } from "types/gallery";
import { downloadFilesWithProgress } from "utils/file";
import { isArchivedCollection, updateMagicMetadata } from "utils/magicMetadata";
import { safeDirectoryName } from "utils/native-fs";
export enum COLLECTION_OPS_TYPE {
ADD,

View File

@@ -1,7 +1,6 @@
import { FILE_TYPE } from "@/media/file-type";
import { isNonWebImageFileExtension } from "@/media/formats";
import { heicToJPEG } from "@/media/heic-convert";
import { decodeLivePhoto } from "@/media/live-photo";
import DownloadManager from "@/new/photos/services/download";
import {
EncryptedEnteFile,
EnteFile,
@@ -12,21 +11,20 @@ import {
FileWithUpdatedMagicMetadata,
} from "@/new/photos/types/file";
import { VISIBILITY_STATE } from "@/new/photos/types/magicMetadata";
import { detectFileTypeInfo } from "@/new/photos/utils/detect-type";
import { mergeMetadata } from "@/new/photos/utils/file";
import { safeFileName } from "@/new/photos/utils/native-fs";
import { writeStream } from "@/new/photos/utils/native-stream";
import { lowercaseExtension } from "@/next/file";
import log from "@/next/log";
import { CustomErrorMessage, type Electron } from "@/next/types/ipc";
import { workerBridge } from "@/next/worker/worker-bridge";
import { type Electron } from "@/next/types/ipc";
import { withTimeout } from "@/utils/promise";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
import type { User } from "@ente/shared/user/types";
import { downloadUsingAnchor } from "@ente/shared/utils";
import { t } from "i18next";
import isElectron from "is-electron";
import { moveToHiddenCollection } from "services/collectionService";
import { detectFileTypeInfo } from "services/detect-type";
import DownloadManager from "services/download";
import { updateFileCreationDateInEXIF } from "services/exif";
import {
deleteFromTrash,
@@ -40,21 +38,6 @@ import {
SetFilesDownloadProgressAttributesCreator,
} from "types/gallery";
import { isArchivedFile, updateMagicMetadata } from "utils/magicMetadata";
import { safeFileName } from "utils/native-fs";
import { writeStream } from "utils/native-stream";
const SUPPORTED_RAW_FORMATS = [
"heic",
"rw2",
"tiff",
"arw",
"cr3",
"cr2",
"nef",
"psd",
"dng",
"tif",
];
export enum FILE_OPS_TYPE {
DOWNLOAD,
@@ -66,22 +49,6 @@ export enum FILE_OPS_TYPE {
DELETE_PERMANENTLY,
}
class ModuleState {
/**
* This will be set to true if we get an error from the Node.js side of our
* desktop app telling us that native JPEG conversion is not available for
* the current OS/arch combination.
*
* That way, we can stop pestering it again and again (saving an IPC
* round-trip).
*
* Note the double negative when it is used.
*/
isNativeJPEGConversionNotAvailable = false;
}
const moduleState = new ModuleState();
/**
* @returns a string to use as an identifier when logging information about the
* given {@link enteFile}. The returned string contains the file name (for ease
@@ -257,80 +224,6 @@ export async function decryptFile(
}
}
/**
* The returned blob.type is filled in, whenever possible, with the MIME type of
* the data that we're dealing with.
*/
export const getRenderableImage = async (fileName: string, imageBlob: Blob) => {
try {
const tempFile = new File([imageBlob], fileName);
const fileTypeInfo = await detectFileTypeInfo(tempFile);
log.debug(
() =>
`Need renderable image for ${JSON.stringify({ fileName, ...fileTypeInfo })}`,
);
const { extension } = fileTypeInfo;
if (!isNonWebImageFileExtension(extension)) {
// Either it is something that the browser already knows how to
// render, or something we don't even about yet.
const mimeType = fileTypeInfo.mimeType;
if (!mimeType) {
log.info(
"Trying to render a file without a MIME type",
fileName,
);
return imageBlob;
} else {
return new Blob([imageBlob], { type: mimeType });
}
}
const available = !moduleState.isNativeJPEGConversionNotAvailable;
if (isElectron() && available && isSupportedRawFormat(extension)) {
// If we're running in our desktop app, see if our Node.js layer can
// convert this into a JPEG using native tools for us.
try {
return await nativeConvertToJPEG(imageBlob);
} catch (e) {
if (e.message.endsWith(CustomErrorMessage.NotAvailable)) {
moduleState.isNativeJPEGConversionNotAvailable = true;
} else {
log.error("Native conversion to JPEG failed", e);
}
}
}
if (extension == "heic" || extension == "heif") {
// For HEIC/HEIF files we can use our web HEIC converter.
return await heicToJPEG(imageBlob);
}
return undefined;
} catch (e) {
log.error(`Failed to get renderable image for ${fileName}`, e);
return undefined;
}
};
const nativeConvertToJPEG = async (imageBlob: Blob) => {
const startTime = Date.now();
const imageData = new Uint8Array(await imageBlob.arrayBuffer());
const electron = globalThis.electron;
// If we're running in a worker, we need to reroute the request back to
// the main thread since workers don't have access to the `window` (and
// thus, to the `window.electron`) object.
const jpegData = electron
? await electron.convertToJPEG(imageData)
: await workerBridge.convertToJPEG(imageData);
log.debug(() => `Native JPEG conversion took ${Date.now() - startTime} ms`);
return new Blob([jpegData], { type: "image/jpeg" });
};
export function isSupportedRawFormat(exactType: string) {
return SUPPORTED_RAW_FORMATS.includes(exactType.toLowerCase());
}
export async function changeFilesVisibility(
files: EnteFile[],
visibility: VISIBILITY_STATE,

View File

@@ -1,6 +1,7 @@
{
"name": "@ente/eslint-config",
"version": "1.0.0",
"version": "0.0.0",
"private": "true",
"main": "index.js",
"dependencies": {},
"devDependencies": {

View File

@@ -1,3 +1,8 @@
module.exports = {
extends: ["@/build-config/eslintrc-react"],
// TODO: These can be removed when we start using ffmpeg upstream. For an reason
// I haven't investigated much, when we run eslint on our CI, it seems to behave
// differently than locally and give a lot of warnings that possibly arise from
// it not being able to locate ffmpeg-wasm.
ignorePatterns: ["**/ffmpeg/worker.ts"],
};

View File

@@ -1,29 +1,34 @@
// TODO: Remove this override
/* eslint-disable @typescript-eslint/no-empty-function */
import { FILE_TYPE } from "@/media/file-type";
import { decodeLivePhoto } from "@/media/live-photo";
import {
import * as ffmpeg from "@/new/photos/services/ffmpeg";
import type {
EnteFile,
type LivePhotoSourceURL,
type SourceURLs,
LivePhotoSourceURL,
SourceURLs,
} from "@/new/photos/types/file";
import { getRenderableImage } from "@/new/photos/utils/file";
import { isDesktop } from "@/next/app";
import { blobCache, type BlobCache } from "@/next/blob-cache";
import log from "@/next/log";
import { customAPIOrigin } from "@/next/origins";
import { ensure } from "@/utils/ensure";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker";
import { CustomError } from "@ente/shared/error";
import { isPlaybackPossible } from "@ente/shared/media/video-playback";
import HTTPService from "@ente/shared/network/HTTPService";
import { retryAsyncFunction } from "@ente/shared/utils";
import type { Remote } from "comlink";
import isElectron from "is-electron";
import * as ffmpeg from "services/ffmpeg";
import { getRenderableImage } from "utils/file";
import { PhotosDownloadClient } from "./clients/photos";
import { PublicAlbumsDownloadClient } from "./clients/publicAlbums";
export type OnDownloadProgress = (event: {
loaded: number;
total: number;
}) => void;
export interface DownloadClient {
interface DownloadClient {
updateTokens: (token: string, passwordToken?: string) => void;
downloadThumbnail: (
file: EnteFile,
@@ -37,8 +42,8 @@ export interface DownloadClient {
}
class DownloadManagerImpl {
private ready: boolean = false;
private downloadClient: DownloadClient;
private ready = false;
private downloadClient: DownloadClient | undefined;
/** Local cache for thumbnails. Might not be available. */
private thumbnailCache?: BlobCache;
/**
@@ -47,11 +52,14 @@ class DownloadManagerImpl {
* Only available when we're running in the desktop app.
*/
private fileCache?: BlobCache;
private cryptoWorker: Remote<DedicatedCryptoWorker>;
private cryptoWorker: Remote<DedicatedCryptoWorker> | undefined;
private fileObjectURLPromises = new Map<number, Promise<SourceURLs>>();
private fileConversionPromises = new Map<number, Promise<SourceURLs>>();
private thumbnailObjectURLPromises = new Map<number, Promise<string>>();
private thumbnailObjectURLPromises = new Map<
number,
Promise<string | undefined>
>();
private fileDownloadProgress = new Map<number, number>();
@@ -86,12 +94,17 @@ class DownloadManagerImpl {
throw new Error(
"Attempting to use an uninitialized download manager",
);
return {
downloadClient: ensure(this.downloadClient),
cryptoWorker: ensure(this.cryptoWorker),
};
}
async logout() {
logout() {
this.ready = false;
this.cryptoWorker = null;
this.downloadClient = null;
this.cryptoWorker = undefined;
this.downloadClient = undefined;
this.fileObjectURLPromises.clear();
this.fileConversionPromises.clear();
this.thumbnailObjectURLPromises.clear();
@@ -100,11 +113,8 @@ class DownloadManagerImpl {
}
updateToken(token: string, passwordToken?: string) {
this.downloadClient.updateTokens(token, passwordToken);
}
updateCryptoWorker(cryptoWorker: Remote<DedicatedCryptoWorker>) {
this.cryptoWorker = cryptoWorker;
const { downloadClient } = this.ensureInitialized();
downloadClient.updateTokens(token, passwordToken);
}
setProgressUpdater(progressUpdater: (value: Map<number, number>) => void) {
@@ -112,10 +122,12 @@ class DownloadManagerImpl {
}
private downloadThumb = async (file: EnteFile) => {
const encrypted = await this.downloadClient.downloadThumbnail(file);
const decrypted = await this.cryptoWorker.decryptThumbnail(
const { downloadClient, cryptoWorker } = this.ensureInitialized();
const encrypted = await downloadClient.downloadThumbnail(file);
const decrypted = await cryptoWorker.decryptThumbnail(
encrypted,
await this.cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
await cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
file.key,
);
return decrypted;
@@ -127,14 +139,17 @@ class DownloadManagerImpl {
const key = file.id.toString();
const cached = await this.thumbnailCache?.get(key);
if (cached) return new Uint8Array(await cached.arrayBuffer());
if (localOnly) return null;
if (localOnly) return undefined;
const thumb = await this.downloadThumb(file);
this.thumbnailCache?.put(key, new Blob([thumb]));
await this.thumbnailCache?.put(key, new Blob([thumb]));
return thumb;
}
async getThumbnailForPreview(file: EnteFile, localOnly = false) {
async getThumbnailForPreview(
file: EnteFile,
localOnly = false,
): Promise<string | undefined> {
this.ensureInitialized();
try {
if (!this.thumbnailObjectURLPromises.has(file.id)) {
@@ -160,15 +175,19 @@ class DownloadManagerImpl {
getFileForPreview = async (
file: EnteFile,
forceConvert = false,
): Promise<SourceURLs> => {
): Promise<SourceURLs | undefined> => {
this.ensureInitialized();
try {
const getFileForPreviewPromise = async () => {
const fileBlob = await new Response(
await this.getFile(file, true),
).blob();
const { url: originalFileURL } =
await this.fileObjectURLPromises.get(file.id);
// TODO: Is this ensure valid?
// The existing code was already dereferencing, so it shouldn't
// affect behaviour.
const { url: originalFileURL } = ensure(
await this.fileObjectURLPromises.get(file.id),
);
const converted = await getRenderableFileURL(
file,
@@ -196,7 +215,7 @@ class DownloadManagerImpl {
async getFile(
file: EnteFile,
cacheInMemory = false,
): Promise<ReadableStream<Uint8Array>> {
): Promise<ReadableStream<Uint8Array> | null> {
this.ensureInitialized();
try {
const getFilePromise = async (): Promise<SourceURLs> => {
@@ -215,7 +234,12 @@ class DownloadManagerImpl {
}
this.fileObjectURLPromises.set(file.id, getFilePromise());
}
const fileURLs = await this.fileObjectURLPromises.get(file.id);
// TODO: Is this ensure valid?
// The existing code was already dereferencing, so it shouldn't
// affect behaviour.
const fileURLs = ensure(
await this.fileObjectURLPromises.get(file.id),
);
if (fileURLs.isOriginal) {
const fileStream = (await fetch(fileURLs.url as string)).body;
return fileStream;
@@ -231,12 +255,15 @@ class DownloadManagerImpl {
private async downloadFile(
file: EnteFile,
): Promise<ReadableStream<Uint8Array>> {
): Promise<ReadableStream<Uint8Array> | null> {
const { downloadClient, cryptoWorker } = this.ensureInitialized();
log.info(`download attempted for file id ${file.id}`);
const onDownloadProgress = this.trackDownloadProgress(
file.id,
file.info?.fileSize,
// TODO: Is info supposed to be optional though?
file.info?.fileSize ?? 0,
);
const cacheKey = file.id.toString();
@@ -248,23 +275,29 @@ class DownloadManagerImpl {
const cachedBlob = await this.fileCache?.get(cacheKey);
let encryptedArrayBuffer = await cachedBlob?.arrayBuffer();
if (!encryptedArrayBuffer) {
const array = await this.downloadClient.downloadFile(
const array = await downloadClient.downloadFile(
file,
onDownloadProgress,
);
encryptedArrayBuffer = array.buffer;
this.fileCache?.put(cacheKey, new Blob([encryptedArrayBuffer]));
await this.fileCache?.put(
cacheKey,
new Blob([encryptedArrayBuffer]),
);
}
this.clearDownloadProgress(file.id);
try {
const decrypted = await this.cryptoWorker.decryptFile(
const decrypted = await cryptoWorker.decryptFile(
new Uint8Array(encryptedArrayBuffer),
await this.cryptoWorker.fromB64(file.file.decryptionHeader),
await cryptoWorker.fromB64(file.file.decryptionHeader),
file.key,
);
return new Response(decrypted).body;
} catch (e) {
if (e.message === CustomError.PROCESSING_FAILED) {
if (
e instanceof Error &&
e.message == CustomError.PROCESSING_FAILED
) {
log.error(
`Failed to process file with fileID:${file.id}, localID: ${file.metadata.localID}, version: ${file.metadata.version}, deviceFolder:${file.metadata.deviceFolder}`,
e,
@@ -278,7 +311,7 @@ class DownloadManagerImpl {
let res: Response;
if (cachedBlob) res = new Response(cachedBlob);
else {
res = await this.downloadClient.downloadFileStream(file);
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
@@ -286,20 +319,20 @@ class DownloadManagerImpl {
// this.fileCache?.put(cacheKey, await res.blob());
}
const reader = res.body.getReader();
const body = res.body;
if (!body) return null;
const reader = body.getReader();
const contentLength = +res.headers.get("Content-Length") ?? 0;
const contentLength =
parseInt(res.headers.get("Content-Length") ?? "") || 0;
let downloadedBytes = 0;
const decryptionHeader = await this.cryptoWorker.fromB64(
const decryptionHeader = await cryptoWorker.fromB64(
file.file.decryptionHeader,
);
const fileKey = await this.cryptoWorker.fromB64(file.key);
const fileKey = await cryptoWorker.fromB64(file.key);
const { pullState, decryptionChunkSize } =
await this.cryptoWorker.initChunkDecryption(
decryptionHeader,
fileKey,
);
await cryptoWorker.initChunkDecryption(decryptionHeader, fileKey);
let leftoverBytes = new Uint8Array();
@@ -333,7 +366,7 @@ class DownloadManagerImpl {
// and we might need multiple iterations to drain it all.
while (data.length >= decryptionChunkSize) {
const { decryptedData } =
await this.cryptoWorker.decryptFileChunk(
await cryptoWorker.decryptFileChunk(
data.slice(0, decryptionChunkSize),
pullState,
);
@@ -347,7 +380,7 @@ class DownloadManagerImpl {
// full chunk, no more bytes are going to come.
if (data.length) {
const { decryptedData } =
await this.cryptoWorker.decryptFileChunk(
await cryptoWorker.decryptFileChunk(
data,
pullState,
);
@@ -395,7 +428,7 @@ const DownloadManager = new DownloadManagerImpl();
export default DownloadManager;
const createDownloadClient = (token: string): DownloadClient => {
const createDownloadClient = (token: string | undefined): DownloadClient => {
const timeout = 300000; // 5 minute
if (token) {
return new PhotosDownloadClient(token, timeout);
@@ -410,14 +443,14 @@ async function getRenderableFileURL(
originalFileURL: string,
forceConvert: boolean,
): Promise<SourceURLs> {
const existingOrNewObjectURL = (convertedBlob: Blob) =>
const existingOrNewObjectURL = (convertedBlob: Blob | null | undefined) =>
convertedBlob
? convertedBlob === fileBlob
? originalFileURL
: URL.createObjectURL(convertedBlob)
: undefined;
let url: SourceURLs["url"];
let url: SourceURLs["url"] | undefined;
let isOriginal: boolean;
let isRenderable: boolean;
let type: SourceURLs["type"] = "normal";
@@ -464,14 +497,15 @@ async function getRenderableFileURL(
}
}
return { url, isOriginal, isRenderable, type, mimeType };
// TODO: Can we remove this ensure and reflect it in the types?
return { url: ensure(url), isOriginal, isRenderable, type, mimeType };
}
async function getRenderableLivePhotoURL(
file: EnteFile,
fileBlob: Blob,
forceConvert: boolean,
): Promise<LivePhotoSourceURL> {
): Promise<LivePhotoSourceURL | undefined> {
const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob);
const getRenderableLivePhotoImageURL = async () => {
@@ -481,11 +515,12 @@ async function getRenderableLivePhotoURL(
livePhoto.imageFileName,
imageBlob,
);
if (!convertedImageBlob) return undefined;
return URL.createObjectURL(convertedImageBlob);
} catch (e) {
//ignore and return null
return null;
return undefined;
}
};
@@ -498,10 +533,11 @@ async function getRenderableLivePhotoURL(
forceConvert,
true,
);
if (!convertedVideoBlob) return undefined;
return URL.createObjectURL(convertedVideoBlob);
} catch (e) {
//ignore and return null
return null;
return undefined;
}
};
@@ -524,7 +560,7 @@ async function getPlayableVideo(
if (isPlayable && !forceConvert) {
return videoBlob;
} else {
if (!forceConvert && !runOnWeb && !isElectron()) {
if (!forceConvert && !runOnWeb && !isDesktop) {
return null;
}
log.info(`Converting video ${videoNameTitle} to mp4`);
@@ -536,3 +572,293 @@ async function getPlayableVideo(
return null;
}
}
class PhotosDownloadClient implements DownloadClient {
constructor(
private token: string,
private timeout: number,
) {}
updateTokens(token: string) {
this.token = token;
}
async downloadThumbnail(file: EnteFile): Promise<Uint8Array> {
const token = this.token;
if (!token) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getThumbnail = () => {
const opts = { responseType: "arraybuffer", timeout: this.timeout };
if (customOrigin) {
const params = new URLSearchParams({ token });
return HTTPService.get(
`${customOrigin}/files/preview/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://thumbnails.ente.io/?fileID=${file.id}`,
undefined,
{ "X-Auth-Token": token },
opts,
);
}
};
const resp = await retryAsyncFunction(getThumbnail);
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
// TODO: Remove this cast (it won't be needed when we migrate this from
// axios to fetch).
return new Uint8Array(resp.data as ArrayBuffer);
}
async downloadFile(
file: EnteFile,
onDownloadProgress: (event: { loaded: number; total: number }) => void,
): Promise<Uint8Array> {
const token = this.token;
if (!token) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getFile = () => {
const opts = {
responseType: "arraybuffer",
timeout: this.timeout,
onDownloadProgress,
};
if (customOrigin) {
const params = new URLSearchParams({ token });
return HTTPService.get(
`${customOrigin}/files/download/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://files.ente.io/?fileID=${file.id}`,
undefined,
{ "X-Auth-Token": token },
opts,
);
}
};
const resp = await retryAsyncFunction(getFile);
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
// TODO: Remove this cast (it won't be needed when we migrate this from
// axios to fetch).
return new Uint8Array(resp.data as ArrayBuffer);
}
async downloadFileStream(file: EnteFile): Promise<Response> {
const token = this.token;
if (!token) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// [Note: Passing credentials for self-hosted file fetches]
//
// Fetching files (or thumbnails) in the default self-hosted Ente
// configuration involves a redirection:
//
// 1. The browser makes a HTTP GET to a museum with credentials. Museum
// inspects the credentials, in this case the auth token, and if
// they're valid, returns a HTTP 307 redirect to the pre-signed S3
// URL that to the file in the configured S3 bucket.
//
// 2. The browser follows the redirect to get the actual file. The URL
// is pre-signed, i.e. already has all credentials needed to prove to
// the S3 object storage that it should serve this response.
//
// For the first step normally we'd pass the auth the token via the
// "X-Auth-Token" HTTP header. In this case though, that would be
// problematic because the browser preserves the request headers when it
// follows the HTTP 307 redirect, and the "X-Auth-Token" header also
// gets sent to the redirected S3 request made in second step.
//
// To avoid this, we pass the token as a query parameter. Generally this
// is not a good idea, but in this case (a) the URL is not a user
// visible one and (b) even if it gets logged, it'll be in the
// self-hosters own service.
//
// Note that Ente's own servers don't have these concerns because we use
// a slightly different flow involving a proxy instead of directly
// connecting to the S3 storage.
//
// 1. The web browser makes a HTTP GET request to a proxy passing it the
// credentials in the "X-Auth-Token".
//
// 2. The proxy then does both the original steps: (a). Use the
// credentials to get the pre signed URL, and (b) fetch that pre
// signed URL and stream back the response.
const getFile = () => {
if (customOrigin) {
const params = new URLSearchParams({ token });
return fetch(
`${customOrigin}/files/download/${file.id}?${params.toString()}`,
);
} else {
return fetch(`https://files.ente.io/?fileID=${file.id}`, {
headers: {
"X-Auth-Token": token,
},
});
}
};
return retryAsyncFunction(getFile);
}
}
class PublicAlbumsDownloadClient implements DownloadClient {
private token: string | undefined;
private passwordToken: string | undefined;
constructor(private timeout: number) {}
updateTokens(token: string, passwordToken?: string) {
this.token = token;
this.passwordToken = passwordToken;
}
downloadThumbnail = async (file: EnteFile) => {
const accessToken = this.token;
const accessTokenJWT = this.passwordToken;
if (!accessToken) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getThumbnail = () => {
const opts = {
responseType: "arraybuffer",
};
if (customOrigin) {
const params = new URLSearchParams({
accessToken,
...(accessTokenJWT && { accessTokenJWT }),
});
return HTTPService.get(
`${customOrigin}/public-collection/files/preview/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://public-albums.ente.io/preview/?fileID=${file.id}`,
undefined,
{
"X-Auth-Access-Token": accessToken,
...(accessTokenJWT && {
"X-Auth-Access-Token-JWT": accessTokenJWT,
}),
},
opts,
);
}
};
const resp = await getThumbnail();
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
// TODO: Remove this cast (it won't be needed when we migrate this from
// axios to fetch).
return new Uint8Array(resp.data as ArrayBuffer);
};
downloadFile = async (
file: EnteFile,
onDownloadProgress: (event: { loaded: number; total: number }) => void,
) => {
const accessToken = this.token;
const accessTokenJWT = this.passwordToken;
if (!accessToken) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getFile = () => {
const opts = {
responseType: "arraybuffer",
timeout: this.timeout,
onDownloadProgress,
};
if (customOrigin) {
const params = new URLSearchParams({
accessToken,
...(accessTokenJWT && { accessTokenJWT }),
});
return HTTPService.get(
`${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`,
undefined,
undefined,
opts,
);
} else {
return HTTPService.get(
`https://public-albums.ente.io/download/?fileID=${file.id}`,
undefined,
{
"X-Auth-Access-Token": accessToken,
...(accessTokenJWT && {
"X-Auth-Access-Token-JWT": accessTokenJWT,
}),
},
opts,
);
}
};
const resp = await retryAsyncFunction(getFile);
if (resp.data === undefined) throw Error(CustomError.REQUEST_FAILED);
// TODO: Remove this cast (it won't be needed when we migrate this from
// axios to fetch).
return new Uint8Array(resp.data as ArrayBuffer);
};
async downloadFileStream(file: EnteFile): Promise<Response> {
const accessToken = this.token;
const accessTokenJWT = this.passwordToken;
if (!accessToken) throw Error(CustomError.TOKEN_MISSING);
const customOrigin = await customAPIOrigin();
// See: [Note: Passing credentials for self-hosted file fetches]
const getFile = () => {
if (customOrigin) {
const params = new URLSearchParams({
accessToken,
...(accessTokenJWT && { accessTokenJWT }),
});
return fetch(
`${customOrigin}/public-collection/files/download/${file.id}?${params.toString()}`,
);
} else {
return fetch(
`https://public-albums.ente.io/download/?fileID=${file.id}`,
{
headers: {
"X-Auth-Access-Token": accessToken,
...(accessTokenJWT && {
"X-Auth-Access-Token-JWT": accessTokenJWT,
}),
},
},
);
}
};
return retryAsyncFunction(getFile);
}
}

View File

@@ -0,0 +1,11 @@
/**
* Name of the directory in which we put our metadata when exporting to the file
* system.
*/
export const exportMetadataDirectoryName = "metadata";
/**
* Name of the directory in which we keep trash items when deleting files that
* have been exported to the local disk previously.
*/
export const exportTrashDirectoryName = "Trash";

View File

@@ -1,3 +1,16 @@
import {
NULL_LOCATION,
toDataOrPathOrZipEntry,
type DesktopUploadItem,
type UploadItem,
} from "@/new/photos/services/upload/types";
import type { ParsedExtractedMetadata } from "@/new/photos/types/metadata";
import {
readConvertToMP4Done,
readConvertToMP4Stream,
writeConvertToMP4Stream,
} from "@/new/photos/utils/native-stream";
import { ensureElectron } from "@/next/electron";
import type { Electron } from "@/next/types/ipc";
import { ComlinkWorker } from "@/next/worker/comlink-worker";
import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time";
@@ -6,20 +19,8 @@ import {
ffmpegPathPlaceholder,
inputPathPlaceholder,
outputPathPlaceholder,
} from "constants/ffmpeg";
import { NULL_LOCATION } from "constants/upload";
import type { ParsedExtractedMetadata } from "types/metadata";
import {
readConvertToMP4Done,
readConvertToMP4Stream,
writeConvertToMP4Stream,
} from "utils/native-stream";
import type { DedicatedFFmpegWorker } from "worker/ffmpeg.worker";
import {
toDataOrPathOrZipEntry,
type DesktopUploadItem,
type UploadItem,
} from "./upload/types";
} from "./constants";
import type { DedicatedFFmpegWorker } from "./worker";
/**
* Generate a thumbnail for the given video using a wasm FFmpeg running in a web
@@ -112,7 +113,7 @@ export const extractVideoMetadata = async (
const outputData =
uploadItem instanceof File
? await ffmpegExecWeb(command, uploadItem, "txt")
: await electron.ffmpegExec(
: await ensureElectron().ffmpegExec(
command,
toDataOrPathOrZipEntry(uploadItem),
"txt",
@@ -164,8 +165,8 @@ function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) {
property.split("="),
);
const validKeyValuePairs = metadataKeyValueArray.filter(
(keyValueArray) => keyValueArray.length === 2,
) as Array<[string, string]>;
(keyValueArray) => keyValueArray.length == 2,
) as [string, string][];
const metadataMap = Object.fromEntries(validKeyValuePairs);
@@ -190,19 +191,19 @@ function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) {
return parsedMetadata;
}
function parseAppleISOLocation(isoLocation: string) {
const parseAppleISOLocation = (isoLocation: string | undefined) => {
let location = { ...NULL_LOCATION };
if (isoLocation) {
const [latitude, longitude] = isoLocation
const m = isoLocation
.match(/(\+|-)\d+\.*\d+/g)
.map((x) => parseFloat(x));
?.map((x) => parseFloat(x));
location = { latitude, longitude };
location = { latitude: m?.at(0) ?? null, longitude: m?.at(1) ?? null };
}
return location;
}
};
function parseCreationTime(creationTime: string) {
const parseCreationTime = (creationTime: string | undefined) => {
let dateTime = null;
if (creationTime) {
dateTime = validateAndGetCreationUnixTimeInMicroSeconds(
@@ -210,7 +211,7 @@ function parseCreationTime(creationTime: string) {
);
}
return dateTime;
}
};
/**
* Run the given FFmpeg command using a wasm FFmpeg running in a web worker.
@@ -258,18 +259,18 @@ export const convertToMP4 = async (blob: Blob): Promise<Blob | Uint8Array> => {
const convertToMP4Native = async (electron: Electron, blob: Blob) => {
const token = await writeConvertToMP4Stream(electron, blob);
const mp4Blob = await readConvertToMP4Stream(electron, token);
readConvertToMP4Done(electron, token);
await readConvertToMP4Done(electron, token);
return mp4Blob;
};
/** Lazily create a singleton instance of our worker */
class WorkerFactory {
private instance: Promise<Remote<DedicatedFFmpegWorker>>;
private instance: Promise<Remote<DedicatedFFmpegWorker>> | undefined;
private createComlinkWorker = () =>
new ComlinkWorker<typeof DedicatedFFmpegWorker>(
"ffmpeg-worker",
new Worker(new URL("worker/ffmpeg.worker.ts", import.meta.url)),
new Worker(new URL("worker.ts", import.meta.url)),
);
async lazy() {

View File

@@ -1,11 +1,12 @@
import log from "@/next/log";
import { ensure } from "@/utils/ensure";
import QueueProcessor from "@ente/shared/utils/queueProcessor";
import { expose } from "comlink";
import {
ffmpegPathPlaceholder,
inputPathPlaceholder,
outputPathPlaceholder,
} from "constants/ffmpeg";
} from "./constants";
// When we run tsc on CI, the line below errors out
//
@@ -22,9 +23,9 @@ import {
// Note that we can't use @ts-expect-error since it doesn't error out when
// actually building!
//
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
// @ts-ignore
import { FFmpeg, createFFmpeg } from "ffmpeg-wasm";
import { createFFmpeg, type FFmpeg } from "ffmpeg-wasm";
export class DedicatedFFmpegWorker {
private ffmpeg: FFmpeg;
@@ -106,7 +107,7 @@ const randomPrefix = () => {
let result = "";
for (let i = 0; i < 10; i++)
result += alphabet[Math.floor(Math.random() * alphabet.length)];
result += ensure(alphabet[Math.floor(Math.random() * alphabet.length)]);
return result;
};
@@ -127,4 +128,5 @@ const substitutePlaceholders = (
return segment;
}
})
.filter((c) => !!c);
// TODO: The type guard should automatically get deduced with TS 5.5
.filter((s): s is string => !!s);

View File

@@ -1,4 +1,5 @@
import type { ZipItem } from "@/next/types/ipc";
import type { Location } from "../../types/metadata";
/**
* An item to upload is one of the following:
@@ -55,3 +56,28 @@ export const toDataOrPathOrZipEntry = (desktopUploadItem: DesktopUploadItem) =>
typeof desktopUploadItem == "string" || Array.isArray(desktopUploadItem)
? desktopUploadItem
: desktopUploadItem.path;
export const RANDOM_PERCENTAGE_PROGRESS_FOR_PUT = () => 90 + 10 * Math.random();
export const NULL_LOCATION: Location = { latitude: null, longitude: null };
export enum UPLOAD_STAGES {
START,
READING_GOOGLE_METADATA_FILES,
EXTRACTING_METADATA,
UPLOADING,
CANCELLING,
FINISH,
}
export enum UPLOAD_RESULT {
FAILED,
ALREADY_UPLOADED,
UNSUPPORTED,
BLOCKED,
TOO_LARGE,
LARGER_THAN_AVAILABLE_STORAGE,
UPLOADED,
UPLOADED_WITH_STATIC_THUMBNAIL,
ADDED_SYMLINK,
}

View File

@@ -26,7 +26,7 @@ export interface EncryptedEnteFile {
file: S3FileAttributes;
thumbnail: S3FileAttributes;
metadata: MetadataFileAttributes;
info: FileInfo;
info: FileInfo | undefined;
magicMetadata: EncryptedMagicMetadata;
pubMagicMetadata: EncryptedMagicMetadata;
encryptedKey: string;
@@ -63,8 +63,8 @@ export interface EnteFile
}
export interface LivePhotoSourceURL {
image: () => Promise<string>;
video: () => Promise<string>;
image: () => Promise<string | undefined>;
video: () => Promise<string | undefined>;
}
export interface LoadedLivePhotoSourceURL {

View File

@@ -0,0 +1,11 @@
export interface Location {
latitude: number | null;
longitude: number | null;
}
export interface ParsedExtractedMetadata {
location: Location;
creationTime: number | null;
width: number | null;
height: number | null;
}

View File

@@ -78,7 +78,7 @@ export const detectFileTypeInfoFromChunk = async (
const known = KnownFileTypeInfos.find((f) => f.extension == extension);
if (known) return known;
if (KnownNonMediaFileExtensions.includes(extension))
if (extension && KnownNonMediaFileExtensions.includes(extension))
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
throw e;

View File

@@ -1,4 +1,27 @@
import { isNonWebImageFileExtension } from "@/media/formats";
import { heicToJPEG } from "@/media/heic-convert";
import { isDesktop } from "@/next/app";
import log from "@/next/log";
import { CustomErrorMessage } from "@/next/types/ipc";
import { workerBridge } from "@/next/worker/worker-bridge";
import type { EnteFile } from "../types/file";
import { detectFileTypeInfo } from "./detect-type";
class ModuleState {
/**
* This will be set to true if we get an error from the Node.js side of our
* desktop app telling us that native JPEG conversion is not available for
* the current OS/arch combination.
*
* That way, we can stop pestering it again and again (saving an IPC
* round-trip).
*
* Note the double negative when it is used.
*/
isNativeJPEGConversionNotAvailable = false;
}
const moduleState = new ModuleState();
/**
* [Note: File name for local EnteFile objects]
@@ -28,3 +51,100 @@ export function mergeMetadata(files: EnteFile[]): EnteFile[] {
return file;
});
}
/**
* The returned blob.type is filled in, whenever possible, with the MIME type of
* the data that we're dealing with.
*/
export const getRenderableImage = async (fileName: string, imageBlob: Blob) => {
try {
const tempFile = new File([imageBlob], fileName);
const fileTypeInfo = await detectFileTypeInfo(tempFile);
log.debug(
() =>
`Need renderable image for ${JSON.stringify({ fileName, ...fileTypeInfo })}`,
);
const { extension } = fileTypeInfo;
if (!isNonWebImageFileExtension(extension)) {
// Either it is something that the browser already knows how to
// render, or something we don't even about yet.
const mimeType = fileTypeInfo.mimeType;
if (!mimeType) {
log.info(
"Trying to render a file without a MIME type",
fileName,
);
return imageBlob;
} else {
return new Blob([imageBlob], { type: mimeType });
}
}
const available = !moduleState.isNativeJPEGConversionNotAvailable;
if (isDesktop && available && isNativeConvertibleToJPEG(extension)) {
// If we're running in our desktop app, see if our Node.js layer can
// convert this into a JPEG using native tools for us.
try {
return await nativeConvertToJPEG(imageBlob);
} catch (e) {
if (
e instanceof Error &&
e.message.endsWith(CustomErrorMessage.NotAvailable)
) {
moduleState.isNativeJPEGConversionNotAvailable = true;
} else {
log.error("Native conversion to JPEG failed", e);
}
}
}
if (extension == "heic" || extension == "heif") {
// For HEIC/HEIF files we can use our web HEIC converter.
return await heicToJPEG(imageBlob);
}
return undefined;
} catch (e) {
log.error(`Failed to get renderable image for ${fileName}`, e);
return undefined;
}
};
/**
* File extensions which our native JPEG conversion code should be able to
* convert to a renderable image.
*/
const convertibleToJPEGExtensions = [
"heic",
"rw2",
"tiff",
"arw",
"cr3",
"cr2",
"nef",
"psd",
"dng",
"tif",
];
/**
* Return true if {@link extension} is amongst the file extensions which we
* expect our native JPEG conversion to be able to process.
*/
export const isNativeConvertibleToJPEG = (extension: string) =>
convertibleToJPEGExtensions.includes(extension.toLowerCase());
const nativeConvertToJPEG = async (imageBlob: Blob) => {
const startTime = Date.now();
const imageData = new Uint8Array(await imageBlob.arrayBuffer());
const electron = globalThis.electron;
// If we're running in a worker, we need to reroute the request back to
// the main thread since workers don't have access to the `window` (and
// thus, to the `window.electron`) object.
const jpegData = electron
? await electron.convertToJPEG(imageData)
: await workerBridge.convertToJPEG(imageData);
log.debug(() => `Native JPEG conversion took ${Date.now() - startTime} ms`);
return new Blob([jpegData], { type: "image/jpeg" });
};

View File

@@ -5,12 +5,12 @@
* written for use by the code that runs in our desktop app.
*/
import { nameAndExtension } from "@/next/file";
import sanitize from "sanitize-filename";
import {
exportMetadataDirectoryName,
exportTrashDirectoryName,
} from "services/export";
} from "@/new/photos/services/export";
import { nameAndExtension } from "@/next/file";
import sanitize from "sanitize-filename";
/**
* Sanitize string for use as file or directory name.

View File

@@ -52,7 +52,7 @@ export const readStream = async (
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to read stream from ${url}: HTTP ${res.status}`,
`Failed to read stream from ${url.href}: HTTP ${res.status}`,
);
const size = readNumericHeader(res, "Content-Length");
@@ -63,7 +63,7 @@ export const readStream = async (
const readNumericHeader = (res: Response, key: string) => {
const valueText = res.headers.get(key);
const value = +valueText;
const value = valueText === null ? NaN : +valueText;
if (isNaN(value))
throw new Error(
`Expected a numeric ${key} when reading a stream response, instead got ${valueText}`,
@@ -111,7 +111,9 @@ export const writeStream = async (
const res = await fetch(req);
if (!res.ok)
throw new Error(`Failed to write stream to ${url}: HTTP ${res.status}`);
throw new Error(
`Failed to write stream to ${url.href}: HTTP ${res.status}`,
);
};
/**
@@ -161,7 +163,7 @@ export const readConvertToMP4Stream = async (
const res = await fetch(req);
if (!res.ok)
throw new Error(
`Failed to read stream from ${url}: HTTP ${res.status}`,
`Failed to read stream from ${url.href}: HTTP ${res.status}`,
);
return res.blob();
@@ -185,5 +187,7 @@ export const readConvertToMP4Done = async (
const req = new Request(url, { method: "GET" });
const res = await fetch(req);
if (!res.ok)
throw new Error(`Failed to close stream at ${url}: HTTP ${res.status}`);
throw new Error(
`Failed to close stream at ${url.href}: HTTP ${res.status}`,
);
};

View File

@@ -1,3 +1,4 @@
import { ensure } from "@/utils/ensure";
import { wait } from "@/utils/promise";
export function downloadAsFile(filename: string, content: string) {
@@ -47,7 +48,7 @@ export async function retryAsyncFunction<T>(
if (attemptNumber === waitTimeBeforeNextTry.length) {
throw e;
}
await wait(waitTimeBeforeNextTry[attemptNumber]);
await wait(ensure(waitTimeBeforeNextTry[attemptNumber]));
}
}
}

View File

@@ -1,9 +1,10 @@
import { ensure } from "@/utils/ensure";
import { CustomError } from "@ente/shared/error";
interface RequestQueueItem {
request: (canceller?: RequestCanceller) => Promise<any>;
successCallback: (response: any) => void;
failureCallback: (error: Error) => void;
failureCallback: (error: unknown) => void;
isCanceled: { status: boolean };
canceller: { exec: () => void };
}
@@ -50,7 +51,7 @@ export default class QueueProcessor<T> {
this.isProcessingRequest = true;
while (this.requestQueue.length > 0) {
const queueItem = this.requestQueue.shift();
const queueItem = ensure(this.requestQueue.shift());
let response = null;
if (queueItem.isCanceled.status) {