[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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 } =
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Location } from "types/metadata";
|
||||
import { Location } from "@/new/photos/types/metadata";
|
||||
|
||||
export enum EntityType {
|
||||
LOCATION_TAG = "location",
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export interface Location {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface ParsedExtractedMetadata {
|
||||
location: Location;
|
||||
creationTime: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@ente/eslint-config",
|
||||
"version": "1.0.0",
|
||||
"version": "0.0.0",
|
||||
"private": "true",
|
||||
"main": "index.js",
|
||||
"dependencies": {},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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"],
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
11
web/packages/new/photos/services/export.ts
Normal file
11
web/packages/new/photos/services/export.ts
Normal 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";
|
||||
@@ -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() {
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
11
web/packages/new/photos/types/metadata.ts
Normal file
11
web/packages/new/photos/types/metadata.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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" });
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
@@ -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]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user