diff --git a/src/components/PhotoSwipe/PhotoSwipe.tsx b/src/components/PhotoSwipe/PhotoSwipe.tsx index 327644956..eec1a7a22 100644 --- a/src/components/PhotoSwipe/PhotoSwipe.tsx +++ b/src/components/PhotoSwipe/PhotoSwipe.tsx @@ -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 = () => ( { 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; diff --git a/src/components/ToastNotification.tsx b/src/components/ToastNotification.tsx new file mode 100644 index 000000000..a885fc4ab --- /dev/null +++ b/src/components/ToastNotification.tsx @@ -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 ( + + + {attributes?.title && ( + +
{attributes.title}
+
+ )} + {attributes?.message && ( + {attributes.message} + )} +
+
+ ); +} diff --git a/src/components/icons/LivePhotoIndicatorOverlay.tsx b/src/components/icons/LivePhotoIndicatorOverlay.tsx new file mode 100644 index 000000000..9d5e26ea8 --- /dev/null +++ b/src/components/icons/LivePhotoIndicatorOverlay.tsx @@ -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 ( + + + + + + + ); +} + +LivePhotoIndicatorOverlay.defaultProps = { + height: 20, + width: 20, + viewBox: '0 0 24 24', +}; diff --git a/src/components/pages/gallery/CollectionNamer.tsx b/src/components/pages/gallery/CollectionNamer.tsx index a94b47b17..8295394e5 100644 --- a/src/components/pages/gallery/CollectionNamer.tsx +++ b/src/components/pages/gallery/CollectionNamer.tsx @@ -53,7 +53,7 @@ export default function CollectionNamer({ attributes, ...props }: Props) { title: attributes?.title, }}> - initialValues={{ albumName: attributes.autoFilledName }} + initialValues={{ albumName: attributes.autoFilledName ?? '' }} validationSchema={Yup.object().shape({ albumName: Yup.string().required(constants.REQUIRED), })} diff --git a/src/components/pages/gallery/PreviewCard.tsx b/src/components/pages/gallery/PreviewCard.tsx index 404c76749..3eb8edfe3 100644 --- a/src/components/pages/gallery/PreviewCard.tsx +++ b/src/components/pages/gallery/PreviewCard.tsx @@ -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) { + {isLivePhoto(file) && } ); } diff --git a/src/components/pages/gallery/Upload.tsx b/src/components/pages/gallery/Upload.tsx index 11a2e41ee..c15725311 100644 --- a/src/components/pages/gallery/Upload.tsx +++ b/src/components/pages/gallery/Upload.tsx @@ -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.START ); + const [filenames, setFilenames] = useState(new Map()); const [fileCounter, setFileCounter] = useState({ finished: 0, total: 0 }); - const [fileProgress, setFileProgress] = useState(new Map()); - const [uploadResult, setUploadResult] = useState(new Map()); + const [fileProgress, setFileProgress] = useState(new Map()); + const [uploadResult, setUploadResult] = useState( + new Map() + ); const [percentComplete, setPercentComplete] = useState(0); + const [hasLivePhotos, setHasLivePhotos] = useState(false); + const [choiceModalView, setChoiceModalView] = useState(false); const [analysisResult, setAnalysisResult] = useState({ 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()); - setUploadResult(new Map()); + setFileProgress(new Map()); + setUploadResult(new Map()); 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) { /> setProgressView(false)} retryFailed={retryFailed} diff --git a/src/components/pages/gallery/UploadProgress.tsx b/src/components/pages/gallery/UploadProgress.tsx index 27c72555c..9fc38a535 100644 --- a/src/components/pages/gallery/UploadProgress.tsx +++ b/src/components/pages/gallery/UploadProgress.tsx @@ -17,13 +17,15 @@ interface Props { now; closeModal; retryFailed; - fileProgress: Map; + fileProgress: Map; + filenames: Map; show; fileRejections: FileRejection[]; - uploadResult: Map; + uploadResult: Map; + hasLivePhotos: boolean; } interface FileProgresses { - fileName: string; + fileID: number; progress: number; } @@ -72,7 +74,8 @@ const NotUploadSectionHeader = styled.div` `; interface ResultSectionProps { - fileUploadResultMap: Map; + filenames: Map; + fileUploadResultMap: Map; fileUploadResult: FileUploadResults; sectionTitle: any; sectionInfo?: any; @@ -95,8 +98,8 @@ const ResultSection = (props: ResultSectionProps) => { {props.sectionInfo} )} - {fileList.map((fileName) => ( -
  • {fileName}
  • + {fileList.map((fileID) => ( +
  • {props.filenames.get(fileID)}
  • ))}
    @@ -106,8 +109,10 @@ const ResultSection = (props: ResultSectionProps) => { }; interface InProgressProps { + filenames: Map; sectionTitle: string; fileProgressStatuses: FileProgresses[]; + sectionInfo?: any; } const InProgressSection = (props: InProgressProps) => { const [listView, setListView] = useState(true); @@ -126,10 +131,15 @@ const InProgressSection = (props: InProgressProps) => { + {props.sectionInfo && ( + {props.sectionInfo} + )} - {fileList.map(({ fileName, progress }) => ( -
  • - {`${fileName} - ${progress}%`} + {fileList.map(({ fileID, progress }) => ( +
  • + {`${props.filenames.get( + fileID + )} - ${progress}%`}
  • ))}
    @@ -141,26 +151,33 @@ const InProgressSection = (props: InProgressProps) => { export default function UploadProgress(props: Props) { const fileProgressStatuses = [] as FileProgresses[]; - const fileUploadResultMap = new Map(); + const fileUploadResultMap = new Map(); 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 ( )} null, startLoading: () => null, finishLoading: () => null, + setNotificationAttributes: () => null, }; export const GalleryContext = createContext( @@ -191,9 +198,14 @@ export default function Gallery() { const [fixCreationTimeAttributes, setFixCreationTimeAttributes] = useState(null); + const [notificationAttributes, setNotificationAttributes] = + useState(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, }}> + { + 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' }); +}; diff --git a/src/services/upload/fileService.ts b/src/services/upload/fileService.ts new file mode 100644 index 000000000..a9c22425c --- /dev/null +++ b/src/services/upload/fileService.ts @@ -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 { + 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 { + 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; + } +} diff --git a/src/services/upload/livePhotoService.ts b/src/services/upload/livePhotoService.ts new file mode 100644 index 000000000..5ad9a2647 --- /dev/null +++ b/src/services/upload/livePhotoService.ts @@ -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; + } +} diff --git a/src/services/upload/metadataService.ts b/src/services/upload/metadataService.ts index cc5a6909f..89564aee5 100644 --- a/src/services/upload/metadataService.ts +++ b/src/services/upload/metadataService.ts @@ -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, diff --git a/src/services/upload/multiPartUploadService.ts b/src/services/upload/multiPartUploadService.ts index ddecde550..822a43999 100644 --- a/src/services/upload/multiPartUploadService.ts +++ b/src/services/upload/multiPartUploadService.ts @@ -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, - 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 ); diff --git a/src/services/upload/readFileService.ts b/src/services/upload/readFileService.ts index b40ccfe38..65508afbc 100644 --- a/src/services/upload/readFileService.ts +++ b/src/services/upload/readFileService.ts @@ -29,12 +29,12 @@ export async function getFileType( ): Promise { 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); } diff --git a/src/services/upload/thumbnailService.ts b/src/services/upload/thumbnailService.ts index 332570a97..b6f1d3903 100644 --- a/src/services/upload/thumbnailService.ts +++ b/src/services/upload/thumbnailService.ts @@ -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'); diff --git a/src/services/upload/uiService.ts b/src/services/upload/uiService.ts index 2d9f67753..8b288ef00 100644 --- a/src/services/upload/uiService.ts +++ b/src/services/upload/uiService.ts @@ -9,8 +9,8 @@ class UIService { private perFileProgress: number; private filesUploaded: number; private totalFileCount: number; - private fileProgress: Map; - private uploadResult: Map; + private fileProgress: Map; + private uploadResult: Map; 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(); - this.uploadResult = new Map(); + this.fileProgress = new Map(); + this.uploadResult = new Map(); 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) { + 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); diff --git a/src/services/upload/uploadManager.ts b/src/services/upload/uploadManager.ts index 927086a8e..eeeceda0f 100644 --- a/src/services/upload/uploadManager.ts +++ b/src/services/upload/uploadManager.ts @@ -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(MAX_CONCURRENT_UPLOADS); - private metadataMap: MetadataMap; + private parsedMetadataJSONMap: ParsedMetadataJSONMap; + private metadataAndFileTypeInfoMap: MetadataAndFileTypeInfoMap; private filesToBeUploaded: FileWithCollection[]; private failedFiles: FileWithCollection[]; private existingFilesCollectionWise: Map; @@ -45,7 +54,11 @@ class UploadManager { private async init(newCollections?: Collection[]) { this.filesToBeUploaded = []; this.failedFiles = []; - this.metadataMap = new Map(); + this.parsedMetadataJSONMap = new Map(); + 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( + 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(); diff --git a/src/services/upload/uploadService.ts b/src/services/upload/uploadService.ts index 54b65b46c..988a02fd4 100644 --- a/src/services/upload/uploadService.ts +++ b/src/services/upload/uploadService.ts @@ -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; + 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 { - 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 { - 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 { - 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 { @@ -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( diff --git a/src/services/upload/uploader.ts b/src/services/upload/uploader.ts index ca9215421..5e9025678 100644 --- a/src/services/upload/uploader.ts +++ b/src/services/upload/uploader.ts @@ -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 { - 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; } } diff --git a/src/types/gallery/index.ts b/src/types/gallery/index.ts index 801aad691..77fba9b3d 100644 --- a/src/types/gallery/index.ts +++ b/src/types/gallery/index.ts @@ -33,4 +33,10 @@ export type GalleryContextType = { setDialogMessage: SetDialogMessage; startLoading: () => void; finishLoading: () => void; + setNotificationAttributes: (attributes: NotificationAttributes) => void; }; + +export interface NotificationAttributes { + message: string; + title: string; +} diff --git a/src/types/upload/index.ts b/src/types/upload/index.ts index 6e58177bc..a49facddf 100644 --- a/src/types/upload/index.ts +++ b/src/types/upload/index.ts @@ -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>; - setFileProgress: React.Dispatch>>; - setUploadResult: React.Dispatch>>; + setFileProgress: React.Dispatch>>; + setUploadResult: React.Dispatch>>; + setFilenames: React.Dispatch>>; + setHasLivePhotos: React.Dispatch>; } -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; +export type MetadataAndFileTypeInfoMap = Map; +export type ParsedMetadataJSONMap = Map; export interface UploadURL { url: string; @@ -91,6 +110,7 @@ export interface FileInMemory { export interface FileWithMetadata extends Omit { 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 {} +export interface BackupedFile extends Omit {} export interface UploadFile extends BackupedFile { collectionID: number; diff --git a/src/utils/error/index.ts b/src/utils/error/index.ts index cb1613f41..57fde1701 100644 --- a/src/utils/error/index.ts +++ b/src/utils/error/index.ts @@ -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) { diff --git a/src/utils/file/index.ts b/src/utils/file/index.ts index 42b71b8bd..b56b519d8 100644 --- a/src/utils/file/index.ts +++ b/src/utils/file/index.ts @@ -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]; diff --git a/src/utils/storage/index.ts b/src/utils/storage/index.ts index 015b03ae9..0b35736cd 100644 --- a/src/utils/storage/index.ts +++ b/src/utils/storage/index.ts @@ -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 }); +} diff --git a/src/utils/storage/localStorage.ts b/src/utils/storage/localStorage.ts index fddfe119e..e8409eb70 100644 --- a/src/utils/storage/localStorage.ts +++ b/src/utils/storage/localStorage.ts @@ -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) => { diff --git a/src/utils/strings/englishConstants.tsx b/src/utils/strings/englishConstants.tsx index cf0385111..b77d46490 100644 --- a/src/utils/strings/englishConstants.tsx +++ b/src/utils/strings/englishConstants.tsx @@ -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: () => ( +

    + the photo and video files from your Live Photos have been merged + into a single ELP file +

    + ), + 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; diff --git a/src/utils/upload/index.ts b/src/utils/upload/index.ts index ebf11f0a3..29ed9ecf2 100644 --- a/src/utils/upload/index.ts +++ b/src/utils/upload/index.ts @@ -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 }; } diff --git a/yarn.lock b/yarn.lock index 41ab0edb9..050077cbd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"