Merge pull request #376 from ente-io/master

release
This commit is contained in:
Vishnu Mohandas 2022-02-16 21:30:20 +05:30 committed by GitHub
commit 73283645a8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
31 changed files with 935 additions and 267 deletions

View file

@ -20,6 +20,7 @@ import {
changeFileName,
downloadFile,
formatDateTime,
isLivePhoto,
splitFilenameAndExtension,
updateExistingFilePubMetadata,
} from 'utils/file';
@ -46,6 +47,10 @@ import { MAX_EDITED_FILE_NAME_LENGTH } from 'constants/file';
import { sleep } from 'utils/common';
import { PublicCollectionGalleryContext } from 'utils/publicCollectionGallery';
import { GalleryContext } from 'pages/gallery';
import {
getLivePhotoInfoShownCount,
setLivePhotoInfoShownCount,
} from 'utils/storage';
const SmallLoadingSpinner = () => (
<EnteSpinner
@ -525,6 +530,19 @@ function PhotoSwipe(props: Iprops) {
setIsFav(isInFav(this?.currItem));
}
function handleLivePhotoNotification() {
if (isLivePhoto(this?.currItem)) {
const infoShownCount = getLivePhotoInfoShownCount();
if (infoShownCount < 3) {
galleryContext.setNotificationAttributes({
message: constants.PLAYBACK_SUPPORT_COMING,
title: constants.LIVE_PHOTO,
});
setLivePhotoInfoShownCount(infoShownCount + 1);
}
}
}
const openPhotoSwipe = () => {
const { items, currentIndex } = props;
const options = {
@ -587,6 +605,7 @@ function PhotoSwipe(props: Iprops) {
photoSwipe.listen('beforeChange', function () {
updateInfo.call(this);
updateFavButton.call(this);
handleLivePhotoNotification.call(this);
});
photoSwipe.listen('resize', checkExifAvailable);
photoSwipe.init();
@ -608,6 +627,8 @@ function PhotoSwipe(props: Iprops) {
videoTag.pause();
}
handleCloseInfo();
// BE_AWARE: this will clear any notification set, even if they were not set in/by the photoswipe component
galleryContext.setNotificationAttributes(null);
};
const isInFav = (file) => {
const { favItemIds } = props;

View file

@ -0,0 +1,59 @@
import React, { useEffect, useState } from 'react';
import { Toast } from 'react-bootstrap';
import styled from 'styled-components';
import { NotificationAttributes } from 'types/gallery';
const Wrapper = styled.div`
position: absolute;
top: 60px;
right: 10px;
z-index: 1501;
min-height: 100px;
`;
const AUTO_HIDE_TIME_IN_MILLISECONDS = 3000;
interface Iprops {
attributes: NotificationAttributes;
clearAttributes: () => void;
}
export default function ToastNotification({
attributes,
clearAttributes,
}: Iprops) {
const [show, setShow] = useState(false);
const closeToast = () => {
setShow(false);
clearAttributes();
};
useEffect(() => {
if (!attributes) {
setShow(false);
} else {
setShow(true);
}
}, [attributes]);
return (
<Wrapper>
<Toast
onClose={closeToast}
show={show}
delay={AUTO_HIDE_TIME_IN_MILLISECONDS}
autohide>
{attributes?.title && (
<Toast.Header
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<h6 style={{ marginBottom: 0 }}>{attributes.title} </h6>
</Toast.Header>
)}
{attributes?.message && (
<Toast.Body>{attributes.message}</Toast.Body>
)}
</Toast>
</Wrapper>
);
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import styled from 'styled-components';
const Wrapper = styled.div`
font-size: 10px;
position: absolute;
padding: 2px;
right: 5px;
top: 5px;
`;
export default function LivePhotoIndicatorOverlay(props) {
return (
<Wrapper>
<svg
xmlns="http://www.w3.org/2000/svg"
height={props.height}
viewBox={props.viewBox}
width={props.width}
fill="currentColor">
<path d="M0 0h24v24H0V0z" fill="none" />
<path d="M6.76 4.84l-1.8-1.79-1.41 1.41 1.79 1.79zM1 10.5h3v2H1zM11 .55h2V3.5h-2zm8.04 2.495l1.408 1.407-1.79 1.79-1.407-1.408zm-1.8 15.115l1.79 1.8 1.41-1.41-1.8-1.79zM20 10.5h3v2h-3zm-8-5c-3.31 0-6 2.69-6 6s2.69 6 6 6 6-2.69 6-6-2.69-6-6-6zm0 10c-2.21 0-4-1.79-4-4s1.79-4 4-4 4 1.79 4 4-1.79 4-4 4zm-1 4h2v2.95h-2zm-7.45-.96l1.41 1.41 1.79-1.8-1.41-1.41z" />
</svg>
</Wrapper>
);
}
LivePhotoIndicatorOverlay.defaultProps = {
height: 20,
width: 20,
viewBox: '0 0 24 24',
};

View file

@ -53,7 +53,7 @@ export default function CollectionNamer({ attributes, ...props }: Props) {
title: attributes?.title,
}}>
<Formik<formValues>
initialValues={{ albumName: attributes.autoFilledName }}
initialValues={{ albumName: attributes.autoFilledName ?? '' }}
validationSchema={Yup.object().shape({
albumName: Yup.string().required(constants.REQUIRED),
})}

View file

@ -11,6 +11,8 @@ import {
PublicCollectionGalleryContext,
} from 'utils/publicCollectionGallery';
import PublicCollectionDownloadManager from 'services/publicCollectionDownloadManager';
import LivePhotoIndicatorOverlay from 'components/icons/LivePhotoIndicatorOverlay';
import { isLivePhoto } from 'utils/file';
interface IProps {
file: EnteFile;
@ -283,6 +285,7 @@ export default function PreviewCard(props: IProps) {
<InSelectRangeOverLay
active={isRangeSelectActive && isInsSelectRange}
/>
{isLivePhoto(file) && <LivePhotoIndicatorOverlay />}
</Cont>
);
}

View file

@ -18,7 +18,7 @@ import { METADATA_FOLDER_NAME } from 'constants/export';
import { getUserFacingErrorMessage } from 'utils/error';
import { Collection } from 'types/collection';
import { SetLoading, SetFiles } from 'types/gallery';
import { UPLOAD_STAGES } from 'constants/upload';
import { FileUploadResults, UPLOAD_STAGES } from 'constants/upload';
import { FileWithCollection } from 'types/upload';
const FIRST_ALBUM_NAME = 'My First Album';
@ -54,10 +54,15 @@ export default function Upload(props: Props) {
const [uploadStage, setUploadStage] = useState<UPLOAD_STAGES>(
UPLOAD_STAGES.START
);
const [filenames, setFilenames] = useState(new Map<number, string>());
const [fileCounter, setFileCounter] = useState({ finished: 0, total: 0 });
const [fileProgress, setFileProgress] = useState(new Map<string, number>());
const [uploadResult, setUploadResult] = useState(new Map<string, number>());
const [fileProgress, setFileProgress] = useState(new Map<number, number>());
const [uploadResult, setUploadResult] = useState(
new Map<number, FileUploadResults>()
);
const [percentComplete, setPercentComplete] = useState(0);
const [hasLivePhotos, setHasLivePhotos] = useState(false);
const [choiceModalView, setChoiceModalView] = useState(false);
const [analysisResult, setAnalysisResult] = useState<AnalysisResult>({
suggestedCollectionName: '',
@ -74,6 +79,8 @@ export default function Upload(props: Props) {
setFileProgress,
setUploadResult,
setUploadStage,
setFilenames,
setHasLivePhotos,
},
props.setFiles
);
@ -107,8 +114,8 @@ export default function Upload(props: Props) {
const uploadInit = function () {
setUploadStage(UPLOAD_STAGES.START);
setFileCounter({ finished: 0, total: 0 });
setFileProgress(new Map<string, number>());
setUploadResult(new Map<string, number>());
setFileProgress(new Map<number, number>());
setUploadResult(new Map<number, number>());
setPercentComplete(0);
props.closeCollectionSelector();
setProgressView(true);
@ -169,8 +176,9 @@ export default function Upload(props: Props) {
try {
uploadInit();
const filesWithCollectionToUpload: FileWithCollection[] =
props.acceptedFiles.map((file) => ({
props.acceptedFiles.map((file, index) => ({
file,
localID: index,
collectionID: collection.id,
}));
await uploadFiles(filesWithCollectionToUpload);
@ -196,18 +204,21 @@ export default function Upload(props: Props) {
}
try {
const existingCollection = await syncCollections();
let index = 0;
for (const [collectionName, files] of collectionWiseFiles) {
const collection = await createAlbum(
collectionName,
existingCollection
);
collections.push(collection);
for (const file of files) {
filesWithCollectionToUpload.push({
filesWithCollectionToUpload.push(
...files.map((file) => ({
localID: index++,
collectionID: collection.id,
file,
});
}
}))
);
}
} catch (e) {
setProgressView(false);
@ -336,9 +347,11 @@ export default function Upload(props: Props) {
/>
<UploadProgress
now={percentComplete}
filenames={filenames}
fileCounter={fileCounter}
uploadStage={uploadStage}
fileProgress={fileProgress}
hasLivePhotos={hasLivePhotos}
show={progressView}
closeModal={() => setProgressView(false)}
retryFailed={retryFailed}

View file

@ -17,13 +17,15 @@ interface Props {
now;
closeModal;
retryFailed;
fileProgress: Map<string, number>;
fileProgress: Map<number, number>;
filenames: Map<number, string>;
show;
fileRejections: FileRejection[];
uploadResult: Map<string, number>;
uploadResult: Map<number, FileUploadResults>;
hasLivePhotos: boolean;
}
interface FileProgresses {
fileName: string;
fileID: number;
progress: number;
}
@ -72,7 +74,8 @@ const NotUploadSectionHeader = styled.div`
`;
interface ResultSectionProps {
fileUploadResultMap: Map<FileUploadResults, string[]>;
filenames: Map<number, string>;
fileUploadResultMap: Map<FileUploadResults, number[]>;
fileUploadResult: FileUploadResults;
sectionTitle: any;
sectionInfo?: any;
@ -95,8 +98,8 @@ const ResultSection = (props: ResultSectionProps) => {
<SectionInfo>{props.sectionInfo}</SectionInfo>
)}
<FileList>
{fileList.map((fileName) => (
<li key={fileName}>{fileName}</li>
{fileList.map((fileID) => (
<li key={fileID}>{props.filenames.get(fileID)}</li>
))}
</FileList>
</Content>
@ -106,8 +109,10 @@ const ResultSection = (props: ResultSectionProps) => {
};
interface InProgressProps {
filenames: Map<number, string>;
sectionTitle: string;
fileProgressStatuses: FileProgresses[];
sectionInfo?: any;
}
const InProgressSection = (props: InProgressProps) => {
const [listView, setListView] = useState(true);
@ -126,10 +131,15 @@ const InProgressSection = (props: InProgressProps) => {
</SectionTitle>
<Collapse isOpened={listView}>
<Content>
{props.sectionInfo && (
<SectionInfo>{props.sectionInfo}</SectionInfo>
)}
<FileList>
{fileList.map(({ fileName, progress }) => (
<li key={fileName}>
{`${fileName} - ${progress}%`}
{fileList.map(({ fileID, progress }) => (
<li key={fileID}>
{`${props.filenames.get(
fileID
)} - ${progress}%`}
</li>
))}
</FileList>
@ -141,26 +151,33 @@ const InProgressSection = (props: InProgressProps) => {
export default function UploadProgress(props: Props) {
const fileProgressStatuses = [] as FileProgresses[];
const fileUploadResultMap = new Map<FileUploadResults, string[]>();
const fileUploadResultMap = new Map<FileUploadResults, number[]>();
let filesNotUploaded = false;
let sectionInfo = null;
if (props.fileProgress) {
for (const [fileName, progress] of props.fileProgress) {
fileProgressStatuses.push({ fileName, progress });
for (const [localID, progress] of props.fileProgress) {
fileProgressStatuses.push({
fileID: localID,
progress,
});
}
}
if (props.uploadResult) {
for (const [fileName, progress] of props.uploadResult) {
for (const [localID, progress] of props.uploadResult) {
if (!fileUploadResultMap.has(progress)) {
fileUploadResultMap.set(progress, []);
}
if (progress < 0) {
if (progress !== FileUploadResults.UPLOADED) {
filesNotUploaded = true;
}
const fileList = fileUploadResultMap.get(progress);
fileUploadResultMap.set(progress, [...fileList, fileName]);
fileUploadResultMap.set(progress, [...fileList, localID]);
}
}
if (props.hasLivePhotos) {
sectionInfo = constants.LIVE_PHOTOS_DETECTED();
}
return (
<Modal
@ -200,11 +217,14 @@ export default function UploadProgress(props: Props) {
/>
)}
<InProgressSection
filenames={props.filenames}
fileProgressStatuses={fileProgressStatuses}
sectionTitle={constants.INPROGRESS_UPLOADS}
sectionInfo={sectionInfo}
/>
<ResultSection
filenames={props.filenames}
fileUploadResultMap={fileUploadResultMap}
fileUploadResult={FileUploadResults.UPLOADED}
sectionTitle={constants.SUCCESSFUL_UPLOADS}
@ -218,6 +238,7 @@ export default function UploadProgress(props: Props) {
)}
<ResultSection
filenames={props.filenames}
fileUploadResultMap={fileUploadResultMap}
fileUploadResult={FileUploadResults.BLOCKED}
sectionTitle={constants.BLOCKED_UPLOADS}
@ -226,17 +247,20 @@ export default function UploadProgress(props: Props) {
)}
/>
<ResultSection
filenames={props.filenames}
fileUploadResultMap={fileUploadResultMap}
fileUploadResult={FileUploadResults.FAILED}
sectionTitle={constants.FAILED_UPLOADS}
/>
<ResultSection
filenames={props.filenames}
fileUploadResultMap={fileUploadResultMap}
fileUploadResult={FileUploadResults.ALREADY_UPLOADED}
sectionTitle={constants.SKIPPED_FILES}
sectionInfo={constants.SKIPPED_INFO}
/>
<ResultSection
filenames={props.filenames}
fileUploadResultMap={fileUploadResultMap}
fileUploadResult={
FileUploadResults.LARGER_THAN_AVAILABLE_STORAGE
@ -247,12 +271,14 @@ export default function UploadProgress(props: Props) {
sectionInfo={constants.LARGER_THAN_AVAILABLE_STORAGE_INFO}
/>
<ResultSection
filenames={props.filenames}
fileUploadResultMap={fileUploadResultMap}
fileUploadResult={FileUploadResults.UNSUPPORTED}
sectionTitle={constants.UNSUPPORTED_FILES}
sectionInfo={constants.UNSUPPORTED_INFO}
/>
<ResultSection
filenames={props.filenames}
fileUploadResultMap={fileUploadResultMap}
fileUploadResult={FileUploadResults.TOO_LARGE}
sectionTitle={constants.TOO_LARGE_UPLOADS}

View file

@ -25,6 +25,7 @@ export const NULL_LOCATION: Location = { latitude: null, longitude: null };
export enum UPLOAD_STAGES {
START,
READING_GOOGLE_METADATA_FILES,
EXTRACTING_METADATA,
UPLOADING,
FINISH,
}
@ -38,3 +39,7 @@ export enum FileUploadResults {
LARGER_THAN_AVAILABLE_STORAGE,
UPLOADED,
}
export const MAX_FILE_SIZE_SUPPORTED = 5 * 1024 * 1024 * 1024; // 5 GB
export const LIVE_PHOTO_ASSET_SIZE_LIMIT = 20 * 1024 * 1024; // 20MB

View file

@ -134,10 +134,10 @@ const GlobalStyles = createGlobalStyle`
min-height: -moz-calc(80% - 3.5rem);
min-height: calc(80% - 3.5rem);
}
.modal .modal-header, .modal .modal-footer {
.modal .modal-header, .modal .modal-footer , .toast-header{
border-color: #444 !important;
}
.modal .modal-header .close {
.modal .modal-header .close, .toast-header .close {
color: #aaa;
text-shadow: none;
}
@ -145,7 +145,11 @@ const GlobalStyles = createGlobalStyle`
z-index:2000;
opacity:0.8 !important;
}
.modal .card , .table {
.toast-header{
border-radius:0px !important;
}
.modal .card , .table , .toast {
background-color: #202020;
border: none;
}
@ -154,7 +158,7 @@ const GlobalStyles = createGlobalStyle`
overflow: hidden;
margin: 0 0 5px 0;
}
.modal-content {
.modal-content ,.toast-header{
border-radius:15px;
background-color:#202020 !important;
}
@ -485,6 +489,7 @@ const GlobalStyles = createGlobalStyle`
.form-check-input:hover, .form-check-label :hover{
cursor:pointer;
}
`;
export const LogoImage = styled.img`

View file

@ -96,9 +96,15 @@ import FixCreationTime, {
} from 'components/FixCreationTime';
import { Collection, CollectionAndItsLatestFile } from 'types/collection';
import { EnteFile } from 'types/file';
import { GalleryContextType, SelectedState, Search } from 'types/gallery';
import {
GalleryContextType,
SelectedState,
Search,
NotificationAttributes,
} from 'types/gallery';
import Collections from 'components/pages/gallery/Collections';
import { VISIBILITY_STATE } from 'constants/file';
import ToastNotification from 'components/ToastNotification';
export const DeadCenter = styled.div`
flex: 1;
@ -125,6 +131,7 @@ const defaultGalleryContext: GalleryContextType = {
setDialogMessage: () => null,
startLoading: () => null,
finishLoading: () => null,
setNotificationAttributes: () => null,
};
export const GalleryContext = createContext<GalleryContextType>(
@ -191,9 +198,14 @@ export default function Gallery() {
const [fixCreationTimeAttributes, setFixCreationTimeAttributes] =
useState<FixCreationTimeAttributes>(null);
const [notificationAttributes, setNotificationAttributes] =
useState<NotificationAttributes>(null);
const showPlanSelectorModal = () => setPlanModalView(true);
const closeMessageDialog = () => setMessageDialogView(false);
const clearNotificationAttributes = () => setNotificationAttributes(null);
useEffect(() => {
const key = getKey(SESSION_KEYS.ENCRYPTION_KEY);
if (!key) {
@ -555,6 +567,7 @@ export default function Gallery() {
setDialogMessage,
startLoading,
finishLoading,
setNotificationAttributes,
}}>
<FullScreenDropZone
getRootProps={getRootProps}
@ -577,6 +590,10 @@ export default function Gallery() {
setLoading={setLoading}
/>
<AlertBanner bannerMessage={bannerMessage} />
<ToastNotification
attributes={notificationAttributes}
clearAttributes={clearNotificationAttributes}
/>
<MessageDialog
size="lg"
show={messageDialogView}

View file

@ -79,7 +79,6 @@ export async function replaceThumbnail(
);
const fileTypeInfo = await getFileType(reader, dummyImageFile);
const { thumbnail: newThumbnail } = await generateThumbnail(
worker,
reader,
dummyImageFile,
fileTypeInfo

View file

@ -32,3 +32,16 @@ export const decodeMotionPhoto = async (
}
return motionPhoto;
};
export const encodeMotionPhoto = async (motionPhoto: MotionPhoto) => {
const zip = new JSZip();
zip.file(
'image' + fileExtensionWithDot(motionPhoto.imageNameTitle),
motionPhoto.image
);
zip.file(
'video' + fileExtensionWithDot(motionPhoto.videoNameTitle),
motionPhoto.video
);
return await zip.generateAsync({ type: 'uint8array' });
};

View file

@ -0,0 +1,105 @@
import {
FileTypeInfo,
FileInMemory,
Metadata,
B64EncryptionResult,
EncryptedFile,
EncryptionResult,
FileWithMetadata,
ParsedMetadataJSONMap,
} from 'types/upload';
import { logError } from 'utils/sentry';
import { encryptFiledata } from './encryptionService';
import { extractMetadata, getMetadataJSONMapKey } from './metadataService';
import { getFileData, getFileOriginalName } from './readFileService';
import { generateThumbnail } from './thumbnailService';
export function getFileSize(file: File) {
return file.size;
}
export function getFilename(file: File) {
return file.name;
}
export async function readFile(
reader: FileReader,
fileTypeInfo: FileTypeInfo,
rawFile: File
): Promise<FileInMemory> {
const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
reader,
rawFile,
fileTypeInfo
);
const filedata = await getFileData(reader, rawFile);
return {
filedata,
thumbnail,
hasStaticThumbnail,
};
}
export async function extractFileMetadata(
parsedMetadataJSONMap: ParsedMetadataJSONMap,
rawFile: File,
collectionID: number,
fileTypeInfo: FileTypeInfo
) {
const originalName = getFileOriginalName(rawFile);
const googleMetadata =
parsedMetadataJSONMap.get(
getMetadataJSONMapKey(collectionID, originalName)
) ?? {};
const extractedMetadata: Metadata = await extractMetadata(
rawFile,
fileTypeInfo
);
for (const [key, value] of Object.entries(googleMetadata)) {
if (!value) {
continue;
}
extractedMetadata[key] = value;
}
return extractedMetadata;
}
export async function encryptFile(
worker: any,
file: FileWithMetadata,
encryptionKey: string
): Promise<EncryptedFile> {
try {
const { key: fileKey, file: encryptedFiledata } = await encryptFiledata(
worker,
file.filedata
);
const { file: encryptedThumbnail }: EncryptionResult =
await worker.encryptThumbnail(file.thumbnail, fileKey);
const { file: encryptedMetadata }: EncryptionResult =
await worker.encryptMetadata(file.metadata, fileKey);
const encryptedKey: B64EncryptionResult = await worker.encryptToB64(
fileKey,
encryptionKey
);
const result: EncryptedFile = {
file: {
file: encryptedFiledata,
thumbnail: encryptedThumbnail,
metadata: encryptedMetadata,
localID: file.localID,
},
fileKey: encryptedKey,
};
return result;
} catch (e) {
logError(e, 'Error encrypting files');
throw e;
}
}

View file

@ -0,0 +1,242 @@
import { FILE_TYPE } from 'constants/file';
import { LIVE_PHOTO_ASSET_SIZE_LIMIT } from 'constants/upload';
import { encodeMotionPhoto } from 'services/motionPhotoService';
import {
FileTypeInfo,
FileWithCollection,
LivePhotoAssets,
Metadata,
} from 'types/upload';
import { CustomError } from 'utils/error';
import { isImageOrVideo, splitFilenameAndExtension } from 'utils/file';
import { logError } from 'utils/sentry';
import { getUint8ArrayView } from './readFileService';
import { generateThumbnail } from './thumbnailService';
import uploadService from './uploadService';
import UploadService from './uploadService';
interface LivePhotoIdentifier {
collectionID: number;
fileType: FILE_TYPE;
name: string;
size: number;
}
interface Asset {
file: File;
metadata: Metadata;
fileTypeInfo: FileTypeInfo;
}
const ENTE_LIVE_PHOTO_FORMAT = 'elp';
const UNDERSCORE_THREE = '_3';
const UNDERSCORE = '_';
export function getLivePhotoFileType(
imageFileTypeInfo: FileTypeInfo,
videoTypeInfo: FileTypeInfo
): FileTypeInfo {
return {
fileType: FILE_TYPE.LIVE_PHOTO,
exactType: `${imageFileTypeInfo.exactType}+${videoTypeInfo.exactType}`,
imageType: imageFileTypeInfo.exactType,
videoType: videoTypeInfo.exactType,
};
}
export function getLivePhotoMetadata(imageMetadata: Metadata) {
return {
...imageMetadata,
title: getLivePhotoName(imageMetadata.title),
fileType: FILE_TYPE.LIVE_PHOTO,
};
}
export function getLivePhotoSize(livePhotoAssets: LivePhotoAssets) {
return livePhotoAssets.image.size + livePhotoAssets.video.size;
}
export function getLivePhotoName(imageTitle: string) {
return `${
splitFilenameAndExtension(imageTitle)[0]
}.${ENTE_LIVE_PHOTO_FORMAT}`;
}
export async function readLivePhoto(
reader: FileReader,
fileTypeInfo: FileTypeInfo,
livePhotoAssets: LivePhotoAssets
) {
const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
reader,
livePhotoAssets.image,
{
exactType: fileTypeInfo.imageType,
fileType: FILE_TYPE.IMAGE,
}
);
const image = await getUint8ArrayView(reader, livePhotoAssets.image);
const video = await getUint8ArrayView(reader, livePhotoAssets.video);
return {
filedata: await encodeMotionPhoto({
image,
video,
imageNameTitle: livePhotoAssets.image.name,
videoNameTitle: livePhotoAssets.video.name,
}),
thumbnail,
hasStaticThumbnail,
};
}
export function clusterLivePhotoFiles(mediaFiles: FileWithCollection[]) {
const analysedMediaFiles: FileWithCollection[] = [];
mediaFiles
.sort((firstMediaFile, secondMediaFile) =>
splitFilenameAndExtension(
firstMediaFile.file.name
)[0].localeCompare(
splitFilenameAndExtension(secondMediaFile.file.name)[0]
)
)
.sort(
(firstMediaFile, secondMediaFile) =>
firstMediaFile.collectionID - secondMediaFile.collectionID
);
let index = 0;
while (index < mediaFiles.length - 1) {
const firstMediaFile = mediaFiles[index];
const secondMediaFile = mediaFiles[index + 1];
const { fileTypeInfo: firstFileTypeInfo, metadata: firstFileMetadata } =
UploadService.getFileMetadataAndFileTypeInfo(
firstMediaFile.localID
);
const {
fileTypeInfo: secondFileFileInfo,
metadata: secondFileMetadata,
} = UploadService.getFileMetadataAndFileTypeInfo(
secondMediaFile.localID
);
const firstFileIdentifier: LivePhotoIdentifier = {
collectionID: firstMediaFile.collectionID,
fileType: firstFileTypeInfo.fileType,
name: firstMediaFile.file.name,
size: firstMediaFile.file.size,
};
const secondFileIdentifier: LivePhotoIdentifier = {
collectionID: secondMediaFile.collectionID,
fileType: secondFileFileInfo.fileType,
name: secondMediaFile.file.name,
size: secondMediaFile.file.size,
};
const firstAsset = {
file: firstMediaFile.file,
metadata: firstFileMetadata,
fileTypeInfo: firstFileTypeInfo,
};
const secondAsset = {
file: secondMediaFile.file,
metadata: secondFileMetadata,
fileTypeInfo: secondFileFileInfo,
};
if (
areFilesLivePhotoAssets(firstFileIdentifier, secondFileIdentifier)
) {
let imageAsset: Asset;
let videoAsset: Asset;
if (
firstFileTypeInfo.fileType === FILE_TYPE.IMAGE &&
secondFileFileInfo.fileType === FILE_TYPE.VIDEO
) {
imageAsset = firstAsset;
videoAsset = secondAsset;
} else {
videoAsset = firstAsset;
imageAsset = secondAsset;
}
const livePhotoLocalID = firstMediaFile.localID;
analysedMediaFiles.push({
localID: livePhotoLocalID,
collectionID: firstMediaFile.collectionID,
isLivePhoto: true,
livePhotoAssets: {
image: imageAsset.file,
video: videoAsset.file,
},
});
const livePhotoFileTypeInfo: FileTypeInfo = getLivePhotoFileType(
imageAsset.fileTypeInfo,
videoAsset.fileTypeInfo
);
const livePhotoMetadata: Metadata = getLivePhotoMetadata(
imageAsset.metadata
);
uploadService.setFileMetadataAndFileTypeInfo(livePhotoLocalID, {
fileTypeInfo: { ...livePhotoFileTypeInfo },
metadata: { ...livePhotoMetadata },
});
index += 2;
} else {
analysedMediaFiles.push({ ...firstMediaFile, isLivePhoto: false });
index += 1;
}
}
if (index === mediaFiles.length - 1) {
analysedMediaFiles.push({ ...mediaFiles[index], isLivePhoto: false });
}
return analysedMediaFiles;
}
function areFilesLivePhotoAssets(
firstFileIdentifier: LivePhotoIdentifier,
secondFileIdentifier: LivePhotoIdentifier
) {
if (
firstFileIdentifier.collectionID ===
secondFileIdentifier.collectionID &&
firstFileIdentifier.fileType !== secondFileIdentifier.fileType &&
isImageOrVideo(firstFileIdentifier.fileType) &&
isImageOrVideo(secondFileIdentifier.fileType) &&
removeUnderscoreThreeSuffix(
splitFilenameAndExtension(firstFileIdentifier.name)[0]
) ===
removeUnderscoreThreeSuffix(
splitFilenameAndExtension(secondFileIdentifier.name)[0]
)
) {
// checks size of live Photo assets are less than allowed limit
// I did that based on the assumption that live photo assets ideally would not be larger than LIVE_PHOTO_ASSET_SIZE_LIMIT
// also zipping library doesn't support stream as a input
if (
firstFileIdentifier.size <= LIVE_PHOTO_ASSET_SIZE_LIMIT &&
secondFileIdentifier.size <= LIVE_PHOTO_ASSET_SIZE_LIMIT
) {
return true;
} else {
logError(
new Error(CustomError.TOO_LARGE_LIVE_PHOTO_ASSETS),
CustomError.TOO_LARGE_LIVE_PHOTO_ASSETS,
{
fileSizes: [
firstFileIdentifier.size,
secondFileIdentifier.size,
],
}
);
}
}
return false;
}
function removeUnderscoreThreeSuffix(filename: string) {
if (filename.endsWith(UNDERSCORE_THREE)) {
return filename.slice(0, filename.lastIndexOf(UNDERSCORE));
} else {
return filename;
}
}

View file

@ -8,6 +8,7 @@ import {
FileTypeInfo,
} from 'types/upload';
import { NULL_LOCATION } from 'constants/upload';
import { splitFilenameAndExtension } from 'utils/file';
interface ParsedMetadataJSONWithTitle {
title: string;
@ -30,7 +31,9 @@ export async function extractMetadata(
}
const extractedMetadata: Metadata = {
title: receivedFile.name,
title: `${splitFilenameAndExtension(receivedFile.name)[0]}.${
fileTypeInfo.exactType
}`,
creationTime:
exifData?.creationTime ?? receivedFile.lastModified * 1000,
modificationTime: receivedFile.lastModified * 1000,
@ -41,8 +44,11 @@ export async function extractMetadata(
return extractedMetadata;
}
export const getMetadataMapKey = (collectionID: number, title: string) =>
`${collectionID}_${title}`;
export const getMetadataJSONMapKey = (
collectionID: number,
title: string
) => `${collectionID}-${title}`;
export async function parseMetadataJSON(
reader: FileReader,

View file

@ -20,7 +20,7 @@ function calculatePartCount(chunkCount: number) {
return partCount;
}
export async function uploadStreamUsingMultipart(
filename: string,
fileLocalID: number,
dataStream: DataStream
) {
const uploadPartCount = calculatePartCount(dataStream.chunkCount);
@ -30,7 +30,7 @@ export async function uploadStreamUsingMultipart(
const fileObjectKey = await uploadStreamInParts(
multipartUploadURLs,
dataStream.stream,
filename,
fileLocalID,
uploadPartCount
);
return fileObjectKey;
@ -39,7 +39,7 @@ export async function uploadStreamUsingMultipart(
export async function uploadStreamInParts(
multipartUploadURLs: MultipartUploadURLs,
dataStream: ReadableStream<Uint8Array>,
filename: string,
fileLocalID: number,
uploadPartCount: number
) {
const streamReader = dataStream.getReader();
@ -52,7 +52,7 @@ export async function uploadStreamInParts(
] of multipartUploadURLs.partURLs.entries()) {
const uploadChunk = await combineChunksToFormUploadPart(streamReader);
const progressTracker = UIService.trackUploadProgress(
filename,
fileLocalID,
percentPerPart,
index
);

View file

@ -29,12 +29,12 @@ export async function getFileType(
): Promise<FileTypeInfo> {
try {
let fileType: FILE_TYPE;
const mimeType = await getMimeType(reader, receivedFile);
const typeParts = mimeType?.split('/');
if (typeParts?.length !== 2) {
const typeResult = await extractFileType(reader, receivedFile);
const mimTypeParts = typeResult.mime?.split('/');
if (mimTypeParts?.length !== 2) {
throw Error(CustomError.TYPE_DETECTION_FAILED);
}
switch (typeParts[0]) {
switch (mimTypeParts[0]) {
case TYPE_IMAGE:
fileType = FILE_TYPE.IMAGE;
break;
@ -44,7 +44,7 @@ export async function getFileType(
default:
fileType = FILE_TYPE.OTHERS;
}
return { fileType, exactType: typeParts[1] };
return { fileType, exactType: typeResult.ext };
} catch (e) {
const fileFormat = getFileExtension(receivedFile.name);
const formatMissedByTypeDetection = FORMAT_MISSED_BY_FILE_TYPE_LIB.find(
@ -85,16 +85,15 @@ export function getFileOriginalName(file: File) {
return originalName;
}
async function getMimeType(reader: FileReader, file: File) {
async function extractFileType(reader: FileReader, file: File) {
const fileChunkBlob = file.slice(0, CHUNK_SIZE_FOR_TYPE_DETECTION);
return getMimeTypeFromBlob(reader, fileChunkBlob);
return getFileTypeFromBlob(reader, fileChunkBlob);
}
export async function getMimeTypeFromBlob(reader: FileReader, fileBlob: Blob) {
export async function getFileTypeFromBlob(reader: FileReader, fileBlob: Blob) {
try {
const initialFiledata = await getUint8ArrayView(reader, fileBlob);
const result = await FileType.fromBuffer(initialFiledata);
return result.mime;
return await FileType.fromBuffer(initialFiledata);
} catch (e) {
throw Error(CustomError.TYPE_DETECTION_FAILED);
}

View file

@ -23,7 +23,6 @@ interface Dimension {
}
export async function generateThumbnail(
worker,
reader: FileReader,
file: File,
fileTypeInfo: FileTypeInfo
@ -35,13 +34,12 @@ export async function generateThumbnail(
try {
if (fileTypeInfo.fileType === FILE_TYPE.IMAGE) {
const isHEIC = isFileHEIC(fileTypeInfo.exactType);
canvas = await generateImageThumbnail(worker, file, isHEIC);
canvas = await generateImageThumbnail(file, isHEIC);
} else {
try {
const thumb = await FFmpegService.generateThumbnail(file);
const dummyImageFile = new File([thumb], file.name);
canvas = await generateImageThumbnail(
worker,
dummyImageFile,
false
);
@ -73,11 +71,7 @@ export async function generateThumbnail(
}
}
export async function generateImageThumbnail(
worker,
file: File,
isHEIC: boolean
) {
export async function generateImageThumbnail(file: File, isHEIC: boolean) {
const canvas = document.createElement('canvas');
const canvasCTX = canvas.getContext('2d');

View file

@ -9,8 +9,8 @@ class UIService {
private perFileProgress: number;
private filesUploaded: number;
private totalFileCount: number;
private fileProgress: Map<string, number>;
private uploadResult: Map<string, FileUploadResults>;
private fileProgress: Map<number, number>;
private uploadResult: Map<number, FileUploadResults>;
private progressUpdater: ProgressUpdater;
init(progressUpdater: ProgressUpdater) {
@ -20,8 +20,8 @@ class UIService {
reset(count: number) {
this.setTotalFileCount(count);
this.filesUploaded = 0;
this.fileProgress = new Map<string, number>();
this.uploadResult = new Map<string, number>();
this.fileProgress = new Map<number, number>();
this.uploadResult = new Map<number, FileUploadResults>();
this.updateProgressBarUI();
}
@ -30,8 +30,8 @@ class UIService {
this.perFileProgress = 100 / this.totalFileCount;
}
setFileProgress(filename: string, progress: number) {
this.fileProgress.set(filename, progress);
setFileProgress(key: number, progress: number) {
this.fileProgress.set(key, progress);
this.updateProgressBarUI();
}
@ -43,14 +43,22 @@ class UIService {
this.progressUpdater.setPercentComplete(percent);
}
setFilenames(filenames: Map<number, string>) {
this.progressUpdater.setFilenames(filenames);
}
setHasLivePhoto(hasLivePhoto: boolean) {
this.progressUpdater.setHasLivePhotos(hasLivePhoto);
}
increaseFileUploaded() {
this.filesUploaded++;
this.updateProgressBarUI();
}
moveFileToResultList(filename: string, uploadResult: FileUploadResults) {
this.uploadResult.set(filename, uploadResult);
this.fileProgress.delete(filename);
moveFileToResultList(key: number, uploadResult: FileUploadResults) {
this.uploadResult.set(key, uploadResult);
this.fileProgress.delete(key);
this.updateProgressBarUI();
}
@ -82,7 +90,7 @@ class UIService {
}
trackUploadProgress(
filename: string,
fileLocalID: number,
percentPerPart = RANDOM_PERCENTAGE_PROGRESS_FOR_PUT(),
index = 0
) {
@ -97,18 +105,16 @@ class UIService {
return {
cancel,
onUploadProgress: (event) => {
filename &&
this.fileProgress.set(
filename,
Math.min(
Math.round(
percentPerPart * index +
(percentPerPart * event.loaded) /
event.total
),
98
)
);
this.fileProgress.set(
fileLocalID,
Math.min(
Math.round(
percentPerPart * index +
(percentPerPart * event.loaded) / event.total
),
98
)
);
this.updateProgressBarUI();
if (event.loaded === event.total) {
clearTimeout(timeout);

View file

@ -8,8 +8,8 @@ import {
removeUnnecessaryFileProps,
} from 'utils/file';
import { logError } from 'utils/sentry';
import { getMetadataMapKey, parseMetadataJSON } from './metadataService';
import { segregateFiles } from 'utils/upload';
import { getMetadataJSONMapKey, parseMetadataJSON } from './metadataService';
import { segregateMetadataAndMediaFiles } from 'utils/upload';
import uploader from './uploader';
import UIService from './uiService';
import UploadService from './uploadService';
@ -18,19 +18,28 @@ import { Collection } from 'types/collection';
import { EnteFile } from 'types/file';
import {
FileWithCollection,
MetadataMap,
MetadataAndFileTypeInfo,
MetadataAndFileTypeInfoMap,
ParsedMetadataJSON,
ParsedMetadataJSONMap,
ProgressUpdater,
} from 'types/upload';
import { UPLOAD_STAGES, FileUploadResults } from 'constants/upload';
import {
UPLOAD_STAGES,
FileUploadResults,
MAX_FILE_SIZE_SUPPORTED,
} from 'constants/upload';
import { ComlinkWorker } from 'utils/comlink';
import { FILE_TYPE } from 'constants/file';
import uiService from './uiService';
const MAX_CONCURRENT_UPLOADS = 4;
const FILE_UPLOAD_COMPLETED = 100;
class UploadManager {
private cryptoWorkers = new Array<ComlinkWorker>(MAX_CONCURRENT_UPLOADS);
private metadataMap: MetadataMap;
private parsedMetadataJSONMap: ParsedMetadataJSONMap;
private metadataAndFileTypeInfoMap: MetadataAndFileTypeInfoMap;
private filesToBeUploaded: FileWithCollection[];
private failedFiles: FileWithCollection[];
private existingFilesCollectionWise: Map<number, EnteFile[]>;
@ -45,7 +54,11 @@ class UploadManager {
private async init(newCollections?: Collection[]) {
this.filesToBeUploaded = [];
this.failedFiles = [];
this.metadataMap = new Map<string, ParsedMetadataJSON>();
this.parsedMetadataJSONMap = new Map<string, ParsedMetadataJSON>();
this.metadataAndFileTypeInfoMap = new Map<
number,
MetadataAndFileTypeInfo
>();
this.existingFiles = await getLocalFiles();
this.existingFilesCollectionWise = sortFilesIntoCollections(
this.existingFiles
@ -65,18 +78,38 @@ class UploadManager {
) {
try {
await this.init(newCreatedCollections);
const { metadataFiles, mediaFiles } = segregateFiles(
fileWithCollectionToBeUploaded
);
if (metadataFiles.length) {
const { metadataJSONFiles, mediaFiles } =
segregateMetadataAndMediaFiles(fileWithCollectionToBeUploaded);
if (metadataJSONFiles.length) {
UIService.setUploadStage(
UPLOAD_STAGES.READING_GOOGLE_METADATA_FILES
);
await this.seedMetadataMap(metadataFiles);
await this.parseMetadataJSONFiles(metadataJSONFiles);
UploadService.setParsedMetadataJSONMap(
this.parsedMetadataJSONMap
);
}
if (mediaFiles.length) {
UIService.setUploadStage(UPLOAD_STAGES.EXTRACTING_METADATA);
await this.extractMetadataFromFiles(mediaFiles);
UploadService.setMetadataAndFileTypeInfoMap(
this.metadataAndFileTypeInfoMap
);
UIService.setUploadStage(UPLOAD_STAGES.START);
await this.uploadMediaFiles(mediaFiles);
const analysedMediaFiles =
UploadService.clusterLivePhotoFiles(mediaFiles);
uiService.setFilenames(
new Map<number, string>(
analysedMediaFiles.map((mediaFile) => [
mediaFile.localID,
UploadService.getAssetName(mediaFile),
])
)
);
UIService.setHasLivePhoto(
mediaFiles.length !== analysedMediaFiles.length
);
await this.uploadMediaFiles(analysedMediaFiles);
}
UIService.setUploadStage(UPLOAD_STAGES.FINISH);
UIService.setPercentComplete(FILE_UPLOAD_COMPLETED);
@ -90,26 +123,27 @@ class UploadManager {
}
}
private async seedMetadataMap(metadataFiles: FileWithCollection[]) {
private async parseMetadataJSONFiles(metadataFiles: FileWithCollection[]) {
try {
UIService.reset(metadataFiles.length);
const reader = new FileReader();
for (const fileWithCollection of metadataFiles) {
const parsedMetadataJSONWithTitle = await parseMetadataJSON(
reader,
fileWithCollection.file
);
if (parsedMetadataJSONWithTitle) {
const { title, parsedMetadataJSON } =
parsedMetadataJSONWithTitle;
this.metadataMap.set(
getMetadataMapKey(
fileWithCollection.collectionID,
title
),
{ ...parsedMetadataJSON }
for (const { file, collectionID } of metadataFiles) {
try {
const parsedMetadataJSONWithTitle = await parseMetadataJSON(
reader,
file
);
UIService.increaseFileUploaded();
if (parsedMetadataJSONWithTitle) {
const { title, parsedMetadataJSON } =
parsedMetadataJSONWithTitle;
this.parsedMetadataJSONMap.set(
getMetadataJSONMapKey(collectionID, title),
parsedMetadataJSON && { ...parsedMetadataJSON }
);
UIService.increaseFileUploaded();
}
} catch (e) {
logError(e, 'parsing failed for a file');
}
}
} catch (e) {
@ -118,11 +152,52 @@ class UploadManager {
}
}
private async extractMetadataFromFiles(mediaFiles: FileWithCollection[]) {
try {
UIService.reset(mediaFiles.length);
const reader = new FileReader();
for (const { file, localID, collectionID } of mediaFiles) {
try {
const { fileTypeInfo, metadata } = await (async () => {
if (file.size >= MAX_FILE_SIZE_SUPPORTED) {
return { fileTypeInfo: null, metadata: null };
}
const fileTypeInfo = await UploadService.getFileType(
reader,
file
);
if (fileTypeInfo.fileType === FILE_TYPE.OTHERS) {
return { fileTypeInfo, metadata: null };
}
const metadata =
(await UploadService.extractFileMetadata(
file,
collectionID,
fileTypeInfo
)) || null;
return { fileTypeInfo, metadata };
})();
this.metadataAndFileTypeInfoMap.set(localID, {
fileTypeInfo: fileTypeInfo && { ...fileTypeInfo },
metadata: metadata && { ...metadata },
});
UIService.increaseFileUploaded();
} catch (e) {
logError(e, 'metadata extraction failed for a file');
}
}
} catch (e) {
logError(e, 'error extracting metadata');
// silently ignore the error
}
}
private async uploadMediaFiles(mediaFiles: FileWithCollection[]) {
this.filesToBeUploaded.push(...mediaFiles);
UIService.reset(mediaFiles.length);
await UploadService.init(mediaFiles.length, this.metadataMap);
await UploadService.setFileCount(mediaFiles.length);
UIService.setUploadStage(UPLOAD_STAGES.UPLOADING);
@ -150,19 +225,15 @@ class UploadManager {
private async uploadNextFileInQueue(worker: any, reader: FileReader) {
while (this.filesToBeUploaded.length > 0) {
const fileWithCollection = this.filesToBeUploaded.pop();
const { collectionID } = fileWithCollection;
const existingFilesInCollection =
this.existingFilesCollectionWise.get(
fileWithCollection.collectionID
) ?? [];
const collection = this.collections.get(
fileWithCollection.collectionID
);
fileWithCollection.collection = collection;
this.existingFilesCollectionWise.get(collectionID) ?? [];
const collection = this.collections.get(collectionID);
const { fileUploadResult, file } = await uploader(
worker,
reader,
existingFilesInCollection,
fileWithCollection
{ ...fileWithCollection, collection }
);
if (fileUploadResult === FileUploadResults.UPLOADED) {
@ -187,7 +258,7 @@ class UploadManager {
}
UIService.moveFileToResultList(
fileWithCollection.file.name,
fileWithCollection.localID,
fileUploadResult
);
UploadService.reducePendingUploadCount();

View file

@ -1,124 +1,131 @@
import { Collection } from 'types/collection';
import { logError } from 'utils/sentry';
import UploadHttpClient from './uploadHttpClient';
import { extractMetadata, getMetadataMapKey } from './metadataService';
import { generateThumbnail } from './thumbnailService';
import { getFileOriginalName, getFileData } from './readFileService';
import { encryptFiledata } from './encryptionService';
import { uploadStreamUsingMultipart } from './multiPartUploadService';
import UIService from './uiService';
import { extractFileMetadata, getFilename } from './fileService';
import { getFileType } from './readFileService';
import { handleUploadError } from 'utils/error';
import {
B64EncryptionResult,
BackupedFile,
EncryptedFile,
EncryptionResult,
FileInMemory,
FileTypeInfo,
FileWithCollection,
FileWithMetadata,
isDataStream,
MetadataMap,
Metadata,
MetadataAndFileTypeInfo,
MetadataAndFileTypeInfoMap,
ParsedMetadataJSON,
ParsedMetadataJSONMap,
ProcessedFile,
UploadAsset,
UploadFile,
UploadURL,
} from 'types/upload';
import {
clusterLivePhotoFiles,
getLivePhotoName,
getLivePhotoSize,
readLivePhoto,
} from './livePhotoService';
import { encryptFile, getFileSize, readFile } from './fileService';
import { uploadStreamUsingMultipart } from './multiPartUploadService';
import UIService from './uiService';
class UploadService {
private uploadURLs: UploadURL[] = [];
private metadataMap: Map<string, ParsedMetadataJSON>;
private parsedMetadataJSONMap: ParsedMetadataJSONMap = new Map<
string,
ParsedMetadataJSON
>();
private metadataAndFileTypeInfoMap: MetadataAndFileTypeInfoMap = new Map<
number,
MetadataAndFileTypeInfo
>();
private pendingUploadCount: number = 0;
async init(fileCount: number, metadataMap: MetadataMap) {
async setFileCount(fileCount: number) {
this.pendingUploadCount = fileCount;
this.metadataMap = metadataMap;
await this.preFetchUploadURLs();
}
setParsedMetadataJSONMap(parsedMetadataJSONMap: ParsedMetadataJSONMap) {
this.parsedMetadataJSONMap = parsedMetadataJSONMap;
}
setMetadataAndFileTypeInfoMap(
metadataAndFileTypeInfoMap: MetadataAndFileTypeInfoMap
) {
this.metadataAndFileTypeInfoMap = metadataAndFileTypeInfoMap;
}
reducePendingUploadCount() {
this.pendingUploadCount--;
}
async readFile(
worker: any,
reader: FileReader,
rawFile: File,
fileTypeInfo: FileTypeInfo
): Promise<FileInMemory> {
const { thumbnail, hasStaticThumbnail } = await generateThumbnail(
worker,
reader,
rawFile,
fileTypeInfo
);
const filedata = await getFileData(reader, rawFile);
return {
filedata,
thumbnail,
hasStaticThumbnail,
};
getAssetSize({ isLivePhoto, file, livePhotoAssets }: UploadAsset) {
return isLivePhoto
? getLivePhotoSize(livePhotoAssets)
: getFileSize(file);
}
async getFileMetadata(
rawFile: File,
collection: Collection,
getAssetName({ isLivePhoto, file, livePhotoAssets }: FileWithCollection) {
return isLivePhoto
? getLivePhotoName(livePhotoAssets.image.name)
: getFilename(file);
}
async getFileType(reader: FileReader, file: File) {
return getFileType(reader, file);
}
async readAsset(
reader: FileReader,
fileTypeInfo: FileTypeInfo,
{ isLivePhoto, file, livePhotoAssets }: UploadAsset
) {
return isLivePhoto
? await readLivePhoto(reader, fileTypeInfo, livePhotoAssets)
: await readFile(reader, fileTypeInfo, file);
}
async extractFileMetadata(
file: File,
collectionID: number,
fileTypeInfo: FileTypeInfo
): Promise<Metadata> {
const originalName = getFileOriginalName(rawFile);
const googleMetadata =
this.metadataMap.get(
getMetadataMapKey(collection.id, originalName)
) ?? {};
const extractedMetadata: Metadata = await extractMetadata(
rawFile,
return extractFileMetadata(
this.parsedMetadataJSONMap,
file,
collectionID,
fileTypeInfo
);
for (const [key, value] of Object.entries(googleMetadata)) {
if (!value) {
continue;
}
extractedMetadata[key] = value;
}
return extractedMetadata;
}
async encryptFile(
getFileMetadataAndFileTypeInfo(localID: number) {
return this.metadataAndFileTypeInfoMap.get(localID);
}
setFileMetadataAndFileTypeInfo(
localID: number,
metadataAndFileTypeInfo: MetadataAndFileTypeInfo
) {
return this.metadataAndFileTypeInfoMap.set(
localID,
metadataAndFileTypeInfo
);
}
clusterLivePhotoFiles(mediaFiles: FileWithCollection[]) {
return clusterLivePhotoFiles(mediaFiles);
}
async encryptAsset(
worker: any,
file: FileWithMetadata,
encryptionKey: string
): Promise<EncryptedFile> {
try {
const { key: fileKey, file: encryptedFiledata } =
await encryptFiledata(worker, file.filedata);
const { file: encryptedThumbnail }: EncryptionResult =
await worker.encryptThumbnail(file.thumbnail, fileKey);
const { file: encryptedMetadata }: EncryptionResult =
await worker.encryptMetadata(file.metadata, fileKey);
const encryptedKey: B64EncryptionResult = await worker.encryptToB64(
fileKey,
encryptionKey
);
const result: EncryptedFile = {
file: {
file: encryptedFiledata,
thumbnail: encryptedThumbnail,
metadata: encryptedMetadata,
filename: file.metadata.title,
},
fileKey: encryptedKey,
};
return result;
} catch (e) {
logError(e, 'Error encrypting files');
throw e;
}
return encryptFile(worker, file, encryptionKey);
}
async uploadToBucket(file: ProcessedFile): Promise<BackupedFile> {
@ -126,12 +133,12 @@ class UploadService {
let fileObjectKey: string = null;
if (isDataStream(file.file.encryptedData)) {
fileObjectKey = await uploadStreamUsingMultipart(
file.filename,
file.localID,
file.file.encryptedData
);
} else {
const progressTracker = UIService.trackUploadProgress(
file.filename
file.localID
);
const fileUploadURL = await this.getUploadURL();
fileObjectKey = await UploadHttpClient.putFile(

View file

@ -6,22 +6,10 @@ import { fileAlreadyInCollection } from 'utils/upload';
import UploadHttpClient from './uploadHttpClient';
import UIService from './uiService';
import UploadService from './uploadService';
import uploadService from './uploadService';
import { getFileType } from './readFileService';
import {
BackupedFile,
EncryptedFile,
FileInMemory,
FileTypeInfo,
FileWithCollection,
FileWithMetadata,
Metadata,
UploadFile,
} from 'types/upload';
import { FILE_TYPE } from 'constants/file';
import { FileUploadResults } from 'constants/upload';
import { FileUploadResults, MAX_FILE_SIZE_SUPPORTED } from 'constants/upload';
import { FileWithCollection, BackupedFile, UploadFile } from 'types/upload';
const FIVE_GB_IN_BYTES = 5 * 1024 * 1024 * 1024;
interface UploadResponse {
fileUploadResult: FileUploadResults;
file?: EnteFile;
@ -32,50 +20,44 @@ export default async function uploader(
existingFilesInCollection: EnteFile[],
fileWithCollection: FileWithCollection
): Promise<UploadResponse> {
const { file: rawFile, collection } = fileWithCollection;
UIService.setFileProgress(rawFile.name, 0);
let file: FileInMemory = null;
let encryptedFile: EncryptedFile = null;
let metadata: Metadata = null;
let fileTypeInfo: FileTypeInfo = null;
let fileWithMetadata: FileWithMetadata = null;
const { collection, localID, ...uploadAsset } = fileWithCollection;
UIService.setFileProgress(localID, 0);
const { fileTypeInfo, metadata } =
UploadService.getFileMetadataAndFileTypeInfo(localID);
try {
if (rawFile.size >= FIVE_GB_IN_BYTES) {
const fileSize = UploadService.getAssetSize(uploadAsset);
if (fileSize >= MAX_FILE_SIZE_SUPPORTED) {
return { fileUploadResult: FileUploadResults.TOO_LARGE };
}
fileTypeInfo = await getFileType(reader, rawFile);
if (fileTypeInfo.fileType === FILE_TYPE.OTHERS) {
throw Error(CustomError.UNSUPPORTED_FILE_FORMAT);
}
metadata = await uploadService.getFileMetadata(
rawFile,
collection,
fileTypeInfo
);
if (!metadata) {
throw Error(CustomError.NO_METADATA);
}
if (fileAlreadyInCollection(existingFilesInCollection, metadata)) {
return { fileUploadResult: FileUploadResults.ALREADY_UPLOADED };
}
file = await UploadService.readFile(
worker,
const file = await UploadService.readAsset(
reader,
rawFile,
fileTypeInfo
fileTypeInfo,
uploadAsset
);
if (file.hasStaticThumbnail) {
metadata.hasStaticThumbnail = true;
}
fileWithMetadata = {
const fileWithMetadata = {
localID,
filedata: file.filedata,
thumbnail: file.thumbnail,
metadata,
};
encryptedFile = await UploadService.encryptFile(
const encryptedFile = await UploadService.encryptAsset(
worker,
fileWithMetadata,
collection.key
@ -117,9 +99,5 @@ export default async function uploader(
default:
return { fileUploadResult: FileUploadResults.FAILED };
}
} finally {
file = null;
fileWithMetadata = null;
encryptedFile = null;
}
}

View file

@ -33,4 +33,10 @@ export type GalleryContextType = {
setDialogMessage: SetDialogMessage;
startLoading: () => void;
finishLoading: () => void;
setNotificationAttributes: (attributes: NotificationAttributes) => void;
};
export interface NotificationAttributes {
message: string;
title: string;
}

View file

@ -48,6 +48,8 @@ export interface MultipartUploadURLs {
export interface FileTypeInfo {
fileType: FILE_TYPE;
exactType: string;
imageType?: string;
videoType?: string;
}
export interface ProgressUpdater {
@ -59,17 +61,34 @@ export interface ProgressUpdater {
}>
>;
setUploadStage: React.Dispatch<React.SetStateAction<UPLOAD_STAGES>>;
setFileProgress: React.Dispatch<React.SetStateAction<Map<string, number>>>;
setUploadResult: React.Dispatch<React.SetStateAction<Map<string, number>>>;
setFileProgress: React.Dispatch<React.SetStateAction<Map<number, number>>>;
setUploadResult: React.Dispatch<React.SetStateAction<Map<number, number>>>;
setFilenames: React.Dispatch<React.SetStateAction<Map<number, string>>>;
setHasLivePhotos: React.Dispatch<React.SetStateAction<boolean>>;
}
export interface FileWithCollection {
file: File;
collectionID?: number;
export interface UploadAsset {
isLivePhoto?: boolean;
file?: File;
livePhotoAssets?: LivePhotoAssets;
}
export interface LivePhotoAssets {
image: globalThis.File;
video: globalThis.File;
}
export interface FileWithCollection extends UploadAsset {
localID: number;
collection?: Collection;
collectionID?: number;
}
export interface MetadataAndFileTypeInfo {
metadata: Metadata;
fileTypeInfo: FileTypeInfo;
}
export type MetadataMap = Map<string, ParsedMetadataJSON>;
export type MetadataAndFileTypeInfoMap = Map<number, MetadataAndFileTypeInfo>;
export type ParsedMetadataJSONMap = Map<string, ParsedMetadataJSON>;
export interface UploadURL {
url: string;
@ -91,6 +110,7 @@ export interface FileInMemory {
export interface FileWithMetadata
extends Omit<FileInMemory, 'hasStaticThumbnail'> {
metadata: Metadata;
localID: number;
}
export interface EncryptedFile {
@ -101,9 +121,9 @@ export interface ProcessedFile {
file: fileAttribute;
thumbnail: fileAttribute;
metadata: fileAttribute;
filename: string;
localID: number;
}
export interface BackupedFile extends Omit<ProcessedFile, 'filename'> {}
export interface BackupedFile extends Omit<ProcessedFile, 'localID'> {}
export interface UploadFile extends BackupedFile {
collectionID: number;

View file

@ -38,6 +38,8 @@ export enum CustomError {
BAD_REQUEST = 'bad request',
SUBSCRIPTION_NEEDED = 'subscription not present',
NOT_FOUND = 'not found ',
NO_METADATA = 'no metadata',
TOO_LARGE_LIVE_PHOTO_ASSETS = 'too large live photo assets',
}
function parseUploadErrorCodes(error) {

View file

@ -7,7 +7,7 @@ import {
PublicMagicMetadataProps,
} from 'types/file';
import { decodeMotionPhoto } from 'services/motionPhotoService';
import { getMimeTypeFromBlob } from 'services/upload/readFileService';
import { getFileTypeFromBlob } from 'services/upload/readFileService';
import DownloadManager from 'services/downloadManager';
import { logError } from 'utils/sentry';
import { User } from 'types/user';
@ -48,8 +48,6 @@ export async function downloadFile(
) {
let fileURL: string;
let tempURL: string;
const a = document.createElement('a');
a.style.display = 'none';
if (accessedThroughSharedURL) {
fileURL = await PublicCollectionDownloadManager.getCachedOriginalFile(
file
@ -95,19 +93,35 @@ export async function downloadFile(
tempEditedFileURL = URL.createObjectURL(fileBlob);
fileURL = tempEditedFileURL;
}
a.href = fileURL;
let tempImageURL: string;
let tempVideoURL: string;
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
a.download = fileNameWithoutExtension(file.metadata.title) + '.zip';
const fileBlob = await (await fetch(fileURL)).blob();
const originalName = fileNameWithoutExtension(file.metadata.title);
const motionPhoto = await decodeMotionPhoto(fileBlob, originalName);
tempImageURL = URL.createObjectURL(new Blob([motionPhoto.image]));
tempVideoURL = URL.createObjectURL(new Blob([motionPhoto.video]));
downloadUsingAnchor(motionPhoto.imageNameTitle, tempImageURL);
downloadUsingAnchor(motionPhoto.videoNameTitle, tempVideoURL);
} else {
a.download = file.metadata.title;
downloadUsingAnchor(file.metadata.title, fileURL);
}
tempURL && URL.revokeObjectURL(tempURL);
tempEditedFileURL && URL.revokeObjectURL(tempEditedFileURL);
tempImageURL && URL.revokeObjectURL(tempImageURL);
tempVideoURL && URL.revokeObjectURL(tempVideoURL);
}
function downloadUsingAnchor(name: string, link: string) {
const a = document.createElement('a');
a.style.display = 'none';
a.href = link;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
tempURL && URL.revokeObjectURL(tempURL);
tempEditedFileURL && URL.revokeObjectURL(tempEditedFileURL);
}
export function isFileHEIC(mimeType: string) {
@ -326,7 +340,8 @@ export async function convertForPreview(file: EnteFile, fileBlob: Blob) {
const reader = new FileReader();
const mimeType =
(await getMimeTypeFromBlob(reader, fileBlob)) ?? typeFromExtension;
(await getFileTypeFromBlob(reader, fileBlob))?.mime ??
typeFromExtension;
if (isFileHEIC(mimeType)) {
fileBlob = await HEICConverter.convert(fileBlob);
}
@ -548,3 +563,9 @@ export function needsConversionForPreview(file: EnteFile) {
return false;
}
}
export const isLivePhoto = (file: EnteFile) =>
file.metadata.fileType === FILE_TYPE.LIVE_PHOTO;
export const isImageOrVideo = (fileType: FILE_TYPE) =>
fileType in [FILE_TYPE.IMAGE, FILE_TYPE.VIDEO];

View file

@ -13,3 +13,11 @@ export const justSignedUp = () =>
export function setJustSignedUp(status) {
setData(LS_KEYS.JUST_SIGNED_UP, { status });
}
export function getLivePhotoInfoShownCount() {
return getData(LS_KEYS.LIVE_PHOTO_INFO_SHOWN_COUNT)?.count ?? 0;
}
export function setLivePhotoInfoShownCount(count) {
setData(LS_KEYS.LIVE_PHOTO_INFO_SHOWN_COUNT, { count });
}

View file

@ -13,6 +13,7 @@ export enum LS_KEYS {
EXPORT = 'export',
AnonymizeUserID = 'anonymizedUserID',
THUMBNAIL_FIX_STATE = 'thumbnailFixState',
LIVE_PHOTO_INFO_SHOWN_COUNT = 'livePhotoInfoShownCount',
}
export const setData = (key: LS_KEYS, value: object) => {

View file

@ -99,13 +99,14 @@ const englishConstants = {
ENTER_ALBUM_NAME: 'album name',
CLOSE: 'close',
NO: 'no',
NOTHING_HERE: 'nothing to see here eyes 👀',
NOTHING_HERE: 'nothing to see here yet 👀',
UPLOAD: {
0: 'preparing to upload',
1: 'reading google metadata files',
2: (fileCounter) =>
2: 'reading file metadata to organize file',
3: (fileCounter) =>
`${fileCounter.finished} / ${fileCounter.total} files backed up`,
3: 'backup complete',
4: 'backup complete',
},
UPLOADING_FILES: 'file upload',
FILE_NOT_UPLOADED_LIST: 'the following files were not uploaded',
@ -516,6 +517,13 @@ const englishConstants = {
</>
),
LIVE_PHOTOS_DETECTED: () => (
<p>
the photo and video files from your Live Photos have been merged
into a single ELP file
</p>
),
RETRY_FAILED: 'retry failed uploads',
FAILED_UPLOADS: 'failed uploads ',
SKIPPED_FILES: 'ignored uploads',
@ -652,6 +660,8 @@ const englishConstants = {
TERM_3: 'I acknowledge that any person who knowingly materially misrepresents that material or activity is infringing may be subject to liability for damages. ',
PRESERVED_BY: 'preserved by',
ENTE_IO: 'ente.io',
PLAYBACK_SUPPORT_COMING: 'playback support coming soon...',
LIVE_PHOTO: 'this is a live photo',
};
export default englishConstants;

View file

@ -30,10 +30,10 @@ export function areFilesSame(
}
}
export function segregateFiles(
export function segregateMetadataAndMediaFiles(
filesWithCollectionToUpload: FileWithCollection[]
) {
const metadataFiles: FileWithCollection[] = [];
const metadataJSONFiles: FileWithCollection[] = [];
const mediaFiles: FileWithCollection[] = [];
filesWithCollectionToUpload.forEach((fileWithCollection) => {
const file = fileWithCollection.file;
@ -42,10 +42,10 @@ export function segregateFiles(
return;
}
if (file.name.toLowerCase().endsWith(TYPE_JSON)) {
metadataFiles.push(fileWithCollection);
metadataJSONFiles.push(fileWithCollection);
} else {
mediaFiles.push(fileWithCollection);
}
});
return { mediaFiles, metadataFiles };
return { mediaFiles, metadataJSONFiles };
}

View file

@ -3472,9 +3472,9 @@ fn-name@~3.0.0:
integrity sha512-eNMNr5exLoavuAMhIUVsOKF79SWd/zG104ef6sxBTSw+cZc6BXdQXDvYcGvp0VbxVVSp1XDUNoz7mg1xMtSznA==
follow-redirects@^1.0.0, follow-redirects@^1.14.0:
version "1.14.7"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.7.tgz#2004c02eb9436eee9a21446a6477debf17e81685"
integrity sha512-+hbxoLbFMbRKDwohX8GkTataGqO6Jb7jGwpAlwgy2bIz25XtRm7KEzJM76R1WiNT5SwZkX4Y75SwBolkpmE7iQ==
version "1.14.8"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
foreach@^2.0.5:
version "2.0.5"