This commit is contained in:
Neeraj Gupta 2024-01-29 11:37:11 +05:30
parent 36cad03c71
commit ac12ce7a19
8 changed files with 2 additions and 2996 deletions

File diff suppressed because it is too large Load diff

View file

@ -1,415 +0,0 @@
import {
generateStreamFromArrayBuffer,
getRenderableFileURL,
createTypedObjectURL,
} from 'utils/file';
import { EnteFile } from 'types/file';
import { FILE_TYPE } from 'constants/file';
import { CustomError } from '@ente/shared/error';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import { CacheStorageService } from './cache/cacheStorageService';
import { CACHES } from 'constants/cache';
import { Remote } from 'comlink';
import { DedicatedCryptoWorker } from 'worker/crypto.worker';
import { LimitedCache } from 'types/cache';
import { retryAsyncFunction } from 'utils/network';
import { getToken } from '@ente/shared/storage/localStorage/helpers';
import { getFileURL, getThumbnailURL } from '@ente/shared/network/api';
import HTTPService from '@ente/shared/network/HTTPService';
import { logError } from '@ente/shared/sentry';
import { addLogLine } from '@ente/shared/logging';
class DownloadManager {
private fileObjectURLPromise = new Map<
string,
Promise<{ original: string[]; converted: string[] }>
>();
private thumbnailObjectURLPromise = new Map<number, Promise<string>>();
private fileDownloadProgress = new Map<number, number>();
private progressUpdater: (value: Map<number, number>) => void = () => {};
private thumbnailCache: LimitedCache;
setProgressUpdater(progressUpdater: (value: Map<number, number>) => void) {
this.progressUpdater = progressUpdater;
}
private async getThumbnailCache() {
try {
if (!this.thumbnailCache) {
this.thumbnailCache = await CacheStorageService.open(
CACHES.THUMBS
);
}
return this.thumbnailCache;
} catch (e) {
return null;
// ignore
}
}
public async getCachedThumbnail(file: EnteFile) {
try {
const thumbnailCache = await this.getThumbnailCache();
const cacheResp: Response = await thumbnailCache?.match(
file.id.toString()
);
if (cacheResp) {
return URL.createObjectURL(await cacheResp.blob());
}
return null;
} catch (e) {
logError(e, 'failed to get cached thumbnail');
throw e;
}
}
public async getThumbnail(
file: EnteFile,
tokenOverride?: string,
usingWorker?: Remote<DedicatedCryptoWorker>,
timeout?: number
) {
try {
const token = tokenOverride || getToken();
if (!token) {
return null;
}
if (!this.thumbnailObjectURLPromise.has(file.id)) {
const downloadPromise = async () => {
const thumbnailCache = await this.getThumbnailCache();
const cachedThumb = await this.getCachedThumbnail(file);
if (cachedThumb) {
return cachedThumb;
}
const thumb = await this.downloadThumb(
token,
file,
usingWorker,
timeout
);
const thumbBlob = new Blob([thumb]);
thumbnailCache
?.put(file.id.toString(), new Response(thumbBlob))
.catch((e) => {
logError(e, 'cache put failed');
// TODO: handle storage full exception.
});
return URL.createObjectURL(thumbBlob);
};
this.thumbnailObjectURLPromise.set(file.id, downloadPromise());
}
return await this.thumbnailObjectURLPromise.get(file.id);
} catch (e) {
this.thumbnailObjectURLPromise.delete(file.id);
logError(e, 'get DownloadManager preview Failed');
throw e;
}
}
downloadThumb = async (
token: string,
file: EnteFile,
usingWorker?: Remote<DedicatedCryptoWorker>,
timeout?: number
) => {
const resp = await HTTPService.get(
getThumbnailURL(file.id),
null,
{ 'X-Auth-Token': token },
{ responseType: 'arraybuffer', timeout }
);
if (typeof resp.data === 'undefined') {
throw Error(CustomError.REQUEST_FAILED);
}
const cryptoWorker =
usingWorker || (await ComlinkCryptoWorker.getInstance());
const decrypted = await cryptoWorker.decryptThumbnail(
new Uint8Array(resp.data),
await cryptoWorker.fromB64(file.thumbnail.decryptionHeader),
file.key
);
return decrypted;
};
getFile = async (file: EnteFile, forPreview = false) => {
const fileKey = forPreview ? `${file.id}_preview` : `${file.id}`;
try {
const getFilePromise = async () => {
const fileStream = await this.downloadFile(file);
const fileBlob = await new Response(fileStream).blob();
if (forPreview) {
return await getRenderableFileURL(file, fileBlob);
} else {
const fileURL = await createTypedObjectURL(
fileBlob,
file.metadata.title
);
return { converted: [fileURL], original: [fileURL] };
}
};
if (!this.fileObjectURLPromise.get(fileKey)) {
this.fileObjectURLPromise.set(fileKey, getFilePromise());
}
const fileURLs = await this.fileObjectURLPromise.get(fileKey);
return fileURLs;
} catch (e) {
this.fileObjectURLPromise.delete(fileKey);
logError(e, 'download manager Failed to get File');
throw e;
}
};
public async getCachedOriginalFile(file: EnteFile) {
return (await this.fileObjectURLPromise.get(file.id.toString()))
?.original;
}
async downloadFile(
file: EnteFile,
tokenOverride?: string,
usingWorker?: Remote<DedicatedCryptoWorker>,
timeout?: number
) {
try {
const cryptoWorker =
usingWorker || (await ComlinkCryptoWorker.getInstance());
const token = tokenOverride || getToken();
if (!token) {
return null;
}
const onDownloadProgress = this.trackDownloadProgress(
file.id,
file.info?.fileSize
);
if (
file.metadata.fileType === FILE_TYPE.IMAGE ||
file.metadata.fileType === FILE_TYPE.LIVE_PHOTO
) {
const resp = await retryAsyncFunction(() =>
HTTPService.get(
getFileURL(file.id),
null,
{ 'X-Auth-Token': token },
{
responseType: 'arraybuffer',
timeout,
onDownloadProgress,
}
)
);
this.clearDownloadProgress(file.id);
if (typeof resp.data === 'undefined') {
throw Error(CustomError.REQUEST_FAILED);
}
try {
const decrypted = await cryptoWorker.decryptFile(
new Uint8Array(resp.data),
await cryptoWorker.fromB64(file.file.decryptionHeader),
file.key
);
return generateStreamFromArrayBuffer(decrypted);
} catch (e) {
if (e.message === CustomError.PROCESSING_FAILED) {
logError(e, 'Failed to process file', {
fileID: file.id,
fromMobile:
!!file.metadata.localID ||
!!file.metadata.deviceFolder ||
!!file.metadata.version,
});
addLogLine(
`Failed to process file with fileID:${file.id}, localID: ${file.metadata.localID}, version: ${file.metadata.version}, deviceFolder:${file.metadata.deviceFolder} with error: ${e.message}`
);
}
throw e;
}
}
const resp = await retryAsyncFunction(() =>
fetch(getFileURL(file.id), {
headers: {
'X-Auth-Token': token,
},
})
);
const reader = resp.body.getReader();
const contentLength = +resp.headers.get('Content-Length') ?? 0;
let downloadedBytes = 0;
const stream = new ReadableStream({
async start(controller) {
try {
const decryptionHeader = await cryptoWorker.fromB64(
file.file.decryptionHeader
);
const fileKey = await cryptoWorker.fromB64(file.key);
const { pullState, decryptionChunkSize } =
await cryptoWorker.initChunkDecryption(
decryptionHeader,
fileKey
);
let data = new Uint8Array();
// The following function handles each data chunk
const push = () => {
// "done" is a Boolean and value a "Uint8Array"
reader.read().then(async ({ done, value }) => {
try {
// Is there more data to read?
if (!done) {
downloadedBytes += value.byteLength;
onDownloadProgress({
loaded: downloadedBytes,
total: contentLength,
});
const buffer = new Uint8Array(
data.byteLength + value.byteLength
);
buffer.set(new Uint8Array(data), 0);
buffer.set(
new Uint8Array(value),
data.byteLength
);
if (
buffer.length > decryptionChunkSize
) {
const fileData = buffer.slice(
0,
decryptionChunkSize
);
try {
const { decryptedData } =
await cryptoWorker.decryptFileChunk(
fileData,
pullState
);
controller.enqueue(
decryptedData
);
data =
buffer.slice(
decryptionChunkSize
);
} catch (e) {
if (
e.message ===
CustomError.PROCESSING_FAILED
) {
logError(
e,
'Failed to process file',
{
fileID: file.id,
fromMobile:
!!file.metadata
.localID ||
!!file.metadata
.deviceFolder ||
!!file.metadata
.version,
}
);
addLogLine(
`Failed to process file ${file.id} from localID: ${file.metadata.localID} version: ${file.metadata.version} deviceFolder:${file.metadata.deviceFolder} with error: ${e.message}`
);
}
throw e;
}
} else {
data = buffer;
}
push();
} else {
if (data) {
try {
const { decryptedData } =
await cryptoWorker.decryptFileChunk(
data,
pullState
);
controller.enqueue(
decryptedData
);
data = null;
} catch (e) {
if (
e.message ===
CustomError.PROCESSING_FAILED
) {
logError(
e,
'Failed to process file',
{
fileID: file.id,
fromMobile:
!!file.metadata
.localID ||
!!file.metadata
.deviceFolder ||
!!file.metadata
.version,
}
);
addLogLine(
`Failed to process file ${file.id} from localID: ${file.metadata.localID} version: ${file.metadata.version} deviceFolder:${file.metadata.deviceFolder} with error: ${e.message}`
);
}
throw e;
}
}
controller.close();
}
} catch (e) {
logError(e, 'Failed to process file chunk');
controller.error(e);
}
});
};
push();
} catch (e) {
logError(e, 'Failed to process file stream');
controller.error(e);
}
},
});
return stream;
} catch (e) {
logError(e, 'Failed to download file');
throw e;
}
}
trackDownloadProgress = (fileID: number, fileSize: number) => {
return (event: { loaded: number; total: number }) => {
if (isNaN(event.total) || event.total === 0) {
if (!fileSize) {
return;
}
event.total = fileSize;
}
if (event.loaded === event.total) {
this.fileDownloadProgress.delete(fileID);
} else {
this.fileDownloadProgress.set(
fileID,
Math.round((event.loaded * 100) / event.total)
);
}
this.progressUpdater(new Map(this.fileDownloadProgress));
};
};
clearDownloadProgress = (fileID: number) => {
this.fileDownloadProgress.delete(fileID);
this.progressUpdater(new Map(this.fileDownloadProgress));
};
}
export default new DownloadManager();

View file

@ -1,312 +0,0 @@
import { getEndpoint } from '@ente/shared/network/api';
import { getToken } from '@ente/shared/storage/localStorage/helpers';
import { Collection } from 'types/collection';
import {
decryptFile,
getLatestVersionFiles,
mergeMetadata,
sortFiles,
} from 'utils/file';
import { eventBus, Events } from './events';
import {
EnteFile,
EncryptedEnteFile,
TrashRequest,
FileWithUpdatedMagicMetadata,
FileWithUpdatedPublicMagicMetadata,
} from 'types/file';
import { SetFiles } from 'types/gallery';
import { BulkUpdateMagicMetadataRequest } from 'types/magicMetadata';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import {
getCollectionLastSyncTime,
setCollectionLastSyncTime,
} from './collectionService';
import { REQUEST_BATCH_SIZE } from 'constants/api';
import { batch } from '@ente/shared/batch';
import HTTPService from '@ente/shared/network/HTTPService';
import localForage from '@ente/shared/storage/localForage';
import { logError } from '@ente/shared/sentry';
import { addLogLine } from '@ente/shared/logging';
const ENDPOINT = getEndpoint();
const FILES_TABLE = 'files';
const HIDDEN_FILES_TABLE = 'hidden-files';
export const getLocalFiles = async (type: 'normal' | 'hidden' = 'normal') => {
const tableName = type === 'normal' ? FILES_TABLE : HIDDEN_FILES_TABLE;
const files: Array<EnteFile> =
(await localForage.getItem<EnteFile[]>(tableName)) || [];
return files;
};
const setLocalFiles = async (type: 'normal' | 'hidden', files: EnteFile[]) => {
try {
const tableName = type === 'normal' ? FILES_TABLE : HIDDEN_FILES_TABLE;
await localForage.setItem(tableName, files);
try {
eventBus.emit(Events.LOCAL_FILES_UPDATED);
} catch (e) {
logError(e, 'Error in localFileUpdated handlers');
}
} catch (e1) {
try {
const storageEstimate = await navigator.storage.estimate();
logError(e1, 'failed to save files to indexedDB', {
storageEstimate,
});
addLogLine(`storage estimate ${JSON.stringify(storageEstimate)}`);
} catch (e2) {
logError(e1, 'failed to save files to indexedDB');
logError(e2, 'failed to get storage stats');
}
throw e1;
}
};
export const getAllLocalFiles = async () => {
const normalFiles = await getLocalFiles('normal');
const hiddenFiles = await getLocalFiles('hidden');
return [...normalFiles, ...hiddenFiles];
};
export const syncFiles = async (
type: 'normal' | 'hidden',
collections: Collection[],
setFiles: SetFiles
) => {
const localFiles = await getLocalFiles(type);
let files = await removeDeletedCollectionFiles(collections, localFiles);
if (files.length !== localFiles.length) {
await setLocalFiles(type, files);
setFiles(sortFiles(mergeMetadata(files)));
}
for (const collection of collections) {
if (!getToken()) {
continue;
}
const lastSyncTime = await getCollectionLastSyncTime(collection);
if (collection.updationTime === lastSyncTime) {
continue;
}
const newFiles = await getFiles(collection, lastSyncTime, setFiles);
files = getLatestVersionFiles([...files, ...newFiles]);
await setLocalFiles(type, files);
setCollectionLastSyncTime(collection, collection.updationTime);
}
return files;
};
const getFiles = async (
collection: Collection,
sinceTime: number,
setFiles: SetFiles
): Promise<EnteFile[]> => {
try {
let decryptedFiles: EnteFile[] = [];
let time = sinceTime;
let resp;
do {
const token = getToken();
if (!token) {
break;
}
resp = await HTTPService.get(
`${ENDPOINT}/collections/v2/diff`,
{
collectionID: collection.id,
sinceTime: time,
},
{
'X-Auth-Token': token,
}
);
const newDecryptedFilesBatch = await Promise.all(
resp.data.diff.map(async (file: EncryptedEnteFile) => {
if (!file.isDeleted) {
return await decryptFile(file, collection.key);
} else {
return file;
}
}) as Promise<EnteFile>[]
);
decryptedFiles = [...decryptedFiles, ...newDecryptedFilesBatch];
setFiles((files) =>
sortFiles(
mergeMetadata(
getLatestVersionFiles([
...(files || []),
...decryptedFiles,
])
)
)
);
if (resp.data.diff.length) {
time = resp.data.diff.slice(-1)[0].updationTime;
}
} while (resp.data.hasMore);
return decryptedFiles;
} catch (e) {
logError(e, 'Get files failed');
throw e;
}
};
const removeDeletedCollectionFiles = async (
collections: Collection[],
files: EnteFile[]
) => {
const syncedCollectionIds = new Set<number>();
for (const collection of collections) {
syncedCollectionIds.add(collection.id);
}
files = files.filter((file) => syncedCollectionIds.has(file.collectionID));
return files;
};
export const trashFiles = async (filesToTrash: EnteFile[]) => {
try {
const token = getToken();
if (!token) {
return;
}
const batchedFilesToTrash = batch(filesToTrash, REQUEST_BATCH_SIZE);
for (const batch of batchedFilesToTrash) {
const trashRequest: TrashRequest = {
items: batch.map((file) => ({
fileID: file.id,
collectionID: file.collectionID,
})),
};
await HTTPService.post(
`${ENDPOINT}/files/trash`,
trashRequest,
null,
{
'X-Auth-Token': token,
}
);
}
} catch (e) {
logError(e, 'trash file failed');
throw e;
}
};
export const deleteFromTrash = async (filesToDelete: number[]) => {
try {
const token = getToken();
if (!token) {
return;
}
const batchedFilesToDelete = batch(filesToDelete, REQUEST_BATCH_SIZE);
for (const batch of batchedFilesToDelete) {
await HTTPService.post(
`${ENDPOINT}/trash/delete`,
{ fileIDs: batch },
null,
{
'X-Auth-Token': token,
}
);
}
} catch (e) {
logError(e, 'deleteFromTrash failed');
throw e;
}
};
export const updateFileMagicMetadata = async (
fileWithUpdatedMagicMetadataList: FileWithUpdatedMagicMetadata[]
) => {
const token = getToken();
if (!token) {
return;
}
const reqBody: BulkUpdateMagicMetadataRequest = { metadataList: [] };
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
for (const {
file,
updatedMagicMetadata,
} of fileWithUpdatedMagicMetadataList) {
const { file: encryptedMagicMetadata } =
await cryptoWorker.encryptMetadata(
updatedMagicMetadata.data,
file.key
);
reqBody.metadataList.push({
id: file.id,
magicMetadata: {
version: updatedMagicMetadata.version,
count: updatedMagicMetadata.count,
data: encryptedMagicMetadata.encryptedData,
header: encryptedMagicMetadata.decryptionHeader,
},
});
}
await HTTPService.put(`${ENDPOINT}/files/magic-metadata`, reqBody, null, {
'X-Auth-Token': token,
});
return fileWithUpdatedMagicMetadataList.map(
({ file, updatedMagicMetadata }): EnteFile => ({
...file,
magicMetadata: {
...updatedMagicMetadata,
version: updatedMagicMetadata.version + 1,
},
})
);
};
export const updateFilePublicMagicMetadata = async (
fileWithUpdatedPublicMagicMetadataList: FileWithUpdatedPublicMagicMetadata[]
): Promise<EnteFile[]> => {
const token = getToken();
if (!token) {
return;
}
const reqBody: BulkUpdateMagicMetadataRequest = { metadataList: [] };
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
for (const {
file,
updatedPublicMagicMetadata: updatePublicMagicMetadata,
} of fileWithUpdatedPublicMagicMetadataList) {
const { file: encryptedPubMagicMetadata } =
await cryptoWorker.encryptMetadata(
updatePublicMagicMetadata.data,
file.key
);
reqBody.metadataList.push({
id: file.id,
magicMetadata: {
version: updatePublicMagicMetadata.version,
count: updatePublicMagicMetadata.count,
data: encryptedPubMagicMetadata.encryptedData,
header: encryptedPubMagicMetadata.decryptionHeader,
},
});
}
await HTTPService.put(
`${ENDPOINT}/files/public-magic-metadata`,
reqBody,
null,
{
'X-Auth-Token': token,
}
);
return fileWithUpdatedPublicMagicMetadataList.map(
({ file, updatedPublicMagicMetadata }): EnteFile => ({
...file,
pubMagicMetadata: {
...updatedPublicMagicMetadata,
version: updatedPublicMagicMetadata.version + 1,
},
})
);
};

View file

@ -1,373 +0,0 @@
import { EXIFLESS_FORMATS, NULL_LOCATION } from 'constants/upload';
import { Location } from 'types/upload';
import exifr from 'exifr';
import piexif from 'piexifjs';
import { FileTypeInfo } from 'types/upload';
import { logError } from '@ente/shared/sentry';
import { validateAndGetCreationUnixTimeInMicroSeconds } from 'utils/time';
import { CustomError } from '@ente/shared/error';
const EXIFR_UNSUPPORTED_FILE_FORMAT_MESSAGE = 'Unknown file format';
type ParsedEXIFData = Record<string, any> &
Partial<{
DateTimeOriginal: Date;
CreateDate: Date;
ModifyDate: Date;
DateCreated: Date;
MetadataDate: Date;
latitude: number;
longitude: number;
imageWidth: number;
imageHeight: number;
}>;
type RawEXIFData = Record<string, any> &
Partial<{
DateTimeOriginal: string;
CreateDate: string;
ModifyDate: string;
DateCreated: string;
MetadataDate: string;
GPSLatitude: number[];
GPSLongitude: number[];
GPSLatitudeRef: string;
GPSLongitudeRef: string;
ImageWidth: number;
ImageHeight: number;
}>;
export async function getParsedExifData(
receivedFile: File,
fileTypeInfo: FileTypeInfo,
tags?: string[]
): Promise<ParsedEXIFData> {
try {
if (EXIFLESS_FORMATS.includes(fileTypeInfo.exactType)) {
return null;
}
const exifData: RawEXIFData = await exifr.parse(receivedFile, {
reviveValues: false,
tiff: true,
xmp: true,
icc: true,
iptc: true,
jfif: true,
ihdr: true,
});
if (!exifData) {
return null;
}
const filteredExifData = tags
? Object.fromEntries(
Object.entries(exifData).filter(([key]) => tags.includes(key))
)
: exifData;
return parseExifData(filteredExifData);
} catch (e) {
if (e.message === EXIFR_UNSUPPORTED_FILE_FORMAT_MESSAGE) {
logError(e, 'exif library unsupported format', {
fileType: fileTypeInfo.exactType,
});
} else {
logError(e, 'get parsed exif data failed', {
fileType: fileTypeInfo.exactType,
});
throw e;
}
}
}
function parseExifData(exifData: RawEXIFData): ParsedEXIFData {
if (!exifData) {
return null;
}
const {
DateTimeOriginal,
CreateDate,
ModifyDate,
DateCreated,
ImageHeight,
ImageWidth,
ExifImageHeight,
ExifImageWidth,
PixelXDimension,
PixelYDimension,
MetadataDate,
...rest
} = exifData;
const parsedExif: ParsedEXIFData = { ...rest };
if (DateTimeOriginal) {
parsedExif.DateTimeOriginal = parseEXIFDate(exifData.DateTimeOriginal);
}
if (CreateDate) {
parsedExif.CreateDate = parseEXIFDate(exifData.CreateDate);
}
if (ModifyDate) {
parsedExif.ModifyDate = parseEXIFDate(exifData.ModifyDate);
}
if (DateCreated) {
parsedExif.DateCreated = parseEXIFDate(exifData.DateCreated);
}
if (MetadataDate) {
parsedExif.MetadataDate = parseEXIFDate(exifData.MetadataDate);
}
if (exifData.GPSLatitude && exifData.GPSLongitude) {
const parsedLocation = parseEXIFLocation(
exifData.GPSLatitude,
exifData.GPSLatitudeRef,
exifData.GPSLongitude,
exifData.GPSLongitudeRef
);
parsedExif.latitude = parsedLocation.latitude;
parsedExif.longitude = parsedLocation.longitude;
}
if (ImageWidth && ImageHeight) {
if (typeof ImageWidth === 'number' && typeof ImageHeight === 'number') {
parsedExif.imageWidth = ImageWidth;
parsedExif.imageHeight = ImageHeight;
} else {
logError(
new Error('ImageWidth or ImageHeight is not a number'),
'Image dimension parsing failed',
{
ImageWidth,
ImageHeight,
}
);
}
} else if (ExifImageWidth && ExifImageHeight) {
if (
typeof ExifImageWidth === 'number' &&
typeof ExifImageHeight === 'number'
) {
parsedExif.imageWidth = ExifImageWidth;
parsedExif.imageHeight = ExifImageHeight;
} else {
logError(
new Error('ExifImageWidth or ExifImageHeight is not a number'),
'Image dimension parsing failed',
{
ExifImageWidth,
ExifImageHeight,
}
);
}
} else if (PixelXDimension && PixelYDimension) {
if (
typeof PixelXDimension === 'number' &&
typeof PixelYDimension === 'number'
) {
parsedExif.imageWidth = PixelXDimension;
parsedExif.imageHeight = PixelYDimension;
} else {
logError(
new Error('PixelXDimension or PixelYDimension is not a number'),
'Image dimension parsing failed',
{
PixelXDimension,
PixelYDimension,
}
);
}
}
return parsedExif;
}
function parseEXIFDate(dateTimeString: string) {
try {
if (typeof dateTimeString !== 'string' || dateTimeString === '') {
throw Error(CustomError.NOT_A_DATE);
}
// Check and parse date in the format YYYYMMDD
if (dateTimeString.length === 8) {
const year = Number(dateTimeString.slice(0, 4));
const month = Number(dateTimeString.slice(4, 6));
const day = Number(dateTimeString.slice(6, 8));
if (
!Number.isNaN(year) &&
!Number.isNaN(month) &&
!Number.isNaN(day)
) {
const date = new Date(year, month - 1, day);
if (!Number.isNaN(+date)) {
return date;
}
}
}
const [year, month, day, hour, minute, second] = dateTimeString
.match(/\d+/g)
.map(Number);
if (
typeof year === 'undefined' ||
Number.isNaN(year) ||
typeof month === 'undefined' ||
Number.isNaN(month) ||
typeof day === 'undefined' ||
Number.isNaN(day)
) {
throw Error(CustomError.NOT_A_DATE);
}
let date: Date;
if (
typeof hour === 'undefined' ||
Number.isNaN(hour) ||
typeof minute === 'undefined' ||
Number.isNaN(minute) ||
typeof second === 'undefined' ||
Number.isNaN(second)
) {
date = new Date(year, month - 1, day);
} else {
date = new Date(year, month - 1, day, hour, minute, second);
}
if (Number.isNaN(+date)) {
throw Error(CustomError.NOT_A_DATE);
}
return date;
} catch (e) {
logError(e, 'parseEXIFDate failed', {
dateTimeString,
});
return null;
}
}
export function parseEXIFLocation(
gpsLatitude: number[],
gpsLatitudeRef: string,
gpsLongitude: number[],
gpsLongitudeRef: string
) {
try {
if (
!Array.isArray(gpsLatitude) ||
!Array.isArray(gpsLongitude) ||
gpsLatitude.length !== 3 ||
gpsLongitude.length !== 3
) {
throw Error(CustomError.NOT_A_LOCATION);
}
const latitude = convertDMSToDD(
gpsLatitude[0],
gpsLatitude[1],
gpsLatitude[2],
gpsLatitudeRef
);
const longitude = convertDMSToDD(
gpsLongitude[0],
gpsLongitude[1],
gpsLongitude[2],
gpsLongitudeRef
);
return { latitude, longitude };
} catch (e) {
logError(e, 'parseEXIFLocation failed', {
gpsLatitude,
gpsLatitudeRef,
gpsLongitude,
gpsLongitudeRef,
});
return NULL_LOCATION;
}
}
function convertDMSToDD(
degrees: number,
minutes: number,
seconds: number,
direction: string
) {
let dd = degrees + minutes / 60 + seconds / (60 * 60);
if (direction === 'S' || direction === 'W') dd *= -1;
return dd;
}
export function getEXIFLocation(exifData: ParsedEXIFData): Location {
if (!exifData || (!exifData.latitude && exifData.latitude !== 0)) {
return NULL_LOCATION;
}
return { latitude: exifData.latitude, longitude: exifData.longitude };
}
export function getEXIFTime(exifData: ParsedEXIFData): number {
if (!exifData) {
return null;
}
const dateTime =
exifData.DateTimeOriginal ??
exifData.DateCreated ??
exifData.CreateDate ??
exifData.MetadataDate ??
exifData.ModifyDate;
if (!dateTime) {
return null;
}
return validateAndGetCreationUnixTimeInMicroSeconds(dateTime);
}
export async function updateFileCreationDateInEXIF(
reader: FileReader,
fileBlob: Blob,
updatedDate: Date
) {
try {
let imageDataURL = await convertImageToDataURL(reader, fileBlob);
imageDataURL =
'data:image/jpeg;base64' +
imageDataURL.slice(imageDataURL.indexOf(','));
const exifObj = piexif.load(imageDataURL);
if (!exifObj['Exif']) {
exifObj['Exif'] = {};
}
exifObj['Exif'][piexif.ExifIFD.DateTimeOriginal] =
convertToExifDateFormat(updatedDate);
const exifBytes = piexif.dump(exifObj);
const exifInsertedFile = piexif.insert(exifBytes, imageDataURL);
return dataURIToBlob(exifInsertedFile);
} catch (e) {
logError(e, 'updateFileModifyDateInEXIF failed');
return fileBlob;
}
}
async function convertImageToDataURL(reader: FileReader, blob: Blob) {
const dataURL = await new Promise<string>((resolve) => {
reader.onload = () => resolve(reader.result as string);
reader.readAsDataURL(blob);
});
return dataURL;
}
function dataURIToBlob(dataURI: string) {
// convert base64 to raw binary data held in a string
// doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
const byteString = atob(dataURI.split(',')[1]);
// separate out the mime component
const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0];
// write the bytes of the string to an ArrayBuffer
const ab = new ArrayBuffer(byteString.length);
// create a view into the buffer
const ia = new Uint8Array(ab);
// set the bytes of the buffer to the correct values
for (let i = 0; i < byteString.length; i++) {
ia[i] = byteString.charCodeAt(i);
}
// write the ArrayBuffer to a blob, and you're done
const blob = new Blob([ab], { type: mimeString });
return blob;
}
function convertToExifDateFormat(date: Date) {
return `${date.getFullYear()}:${
date.getMonth() + 1
}:${date.getDate()} ${date.getHours()}:${date.getMinutes()}:${date.getSeconds()}`;
}

View file

@ -1,6 +1,5 @@
import {
getNonEmptyCollections,
updateCollectionMagicMetadata,
updatePublicCollectionMagicMetadata,
} from 'services/collectionService';
import { EnteFile } from 'types/file';
@ -8,25 +7,16 @@ import { logError } from '@ente/shared/sentry';
import {
COLLECTION_ROLE,
Collection,
CollectionMagicMetadataProps,
CollectionPublicMagicMetadataProps,
CollectionSummaries,
} from 'types/collection';
import {
CollectionSummaryType,
CollectionType,
HIDE_FROM_COLLECTION_BAR_TYPES,
OPTIONS_NOT_HAVING_COLLECTION_TYPES,
SYSTEM_COLLECTION_TYPES,
MOVE_TO_NOT_ALLOWED_COLLECTION,
ADD_TO_NOT_ALLOWED_COLLECTION,
} from 'constants/collection';
import { getUnixTimeInMicroSecondsWithDelta } from 'utils/time';
import { SUB_TYPE, VISIBILITY_STATE } from 'types/magicMetadata';
import { isArchivedCollection, updateMagicMetadata } from 'utils/magicMetadata';
import bs58 from 'bs58';
import { t } from 'i18next';
import { getAlbumsURL } from '@ente/shared/network/api';
import { updateMagicMetadata } from 'utils/magicMetadata';
import { User } from '@ente/shared/user/types';
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
@ -45,59 +35,6 @@ export function getSelectedCollection(
return collections.find((collection) => collection.id === collectionID);
}
export function appendCollectionKeyToShareURL(
url: string,
collectionKey: string
) {
if (!url) {
return null;
}
const sharableURL = new URL(url);
const albumsURL = new URL(getAlbumsURL());
sharableURL.protocol = albumsURL.protocol;
sharableURL.host = albumsURL.host;
sharableURL.pathname = albumsURL.pathname;
const bytes = Buffer.from(collectionKey, 'base64');
sharableURL.hash = bs58.encode(bytes);
return sharableURL.href;
}
const _intSelectOption = (i: number) => {
const label = i === 0 ? t('NO_DEVICE_LIMIT') : i.toString();
return { label, value: i };
};
export function getDeviceLimitOptions() {
return [0, 2, 5, 10, 25, 50].map((i) => _intSelectOption(i));
}
export const shareExpiryOptions = () => [
{ label: t('NEVER'), value: () => 0 },
{
label: t('AFTER_TIME.HOUR'),
value: () => getUnixTimeInMicroSecondsWithDelta({ hours: 1 }),
},
{
label: t('AFTER_TIME.DAY'),
value: () => getUnixTimeInMicroSecondsWithDelta({ days: 1 }),
},
{
label: t('AFTER_TIME.WEEK'),
value: () => getUnixTimeInMicroSecondsWithDelta({ days: 7 }),
},
{
label: t('AFTER_TIME.MONTH'),
value: () => getUnixTimeInMicroSecondsWithDelta({ months: 1 }),
},
{
label: t('AFTER_TIME.YEAR'),
value: () => getUnixTimeInMicroSecondsWithDelta({ years: 1 }),
},
];
export const changeCollectionSortOrder = async (
collection: Collection,
asc: boolean
@ -123,85 +60,6 @@ export const changeCollectionSortOrder = async (
}
};
export const changeCollectionOrder = async (
collection: Collection,
order: number
) => {
try {
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
order,
};
const updatedMagicMetadata = await updateMagicMetadata(
updatedMagicMetadataProps,
collection.magicMetadata,
collection.key
);
await updateCollectionMagicMetadata(collection, updatedMagicMetadata);
} catch (e) {
logError(e, 'change collection order failed');
}
};
export const changeCollectionSubType = async (
collection: Collection,
subType: SUB_TYPE
) => {
try {
const updatedMagicMetadataProps: CollectionMagicMetadataProps = {
subType: subType,
};
const updatedMagicMetadata = await updateMagicMetadata(
updatedMagicMetadataProps,
collection.magicMetadata,
collection.key
);
await updateCollectionMagicMetadata(collection, updatedMagicMetadata);
} catch (e) {
logError(e, 'change collection subType failed');
throw e;
}
};
export const getArchivedCollections = (collections: Collection[]) => {
return new Set<number>(
collections
.filter(isArchivedCollection)
.map((collection) => collection.id)
);
};
export const getDefaultHiddenCollectionIDs = (collections: Collection[]) => {
return new Set<number>(
collections
.filter(isDefaultHiddenCollection)
.map((collection) => collection.id)
);
};
export const hasNonSystemCollections = (
collectionSummaries: CollectionSummaries
) => {
for (const collectionSummary of collectionSummaries.values()) {
if (!isSystemCollection(collectionSummary.type)) return true;
}
return false;
};
export const isMoveToAllowedCollection = (type: CollectionSummaryType) => {
return !MOVE_TO_NOT_ALLOWED_COLLECTION.has(type);
};
export const isAddToAllowedCollection = (type: CollectionSummaryType) => {
return !ADD_TO_NOT_ALLOWED_COLLECTION.has(type);
};
export const isSystemCollection = (type: CollectionSummaryType) => {
return SYSTEM_COLLECTION_TYPES.has(type);
};
export const shouldShowOptions = (type: CollectionSummaryType) => {
return !OPTIONS_NOT_HAVING_COLLECTION_TYPES.has(type);
};

View file

@ -1,77 +0,0 @@
import { NULL_LOCATION } from 'constants/upload';
import { ParsedExtractedMetadata } from 'types/upload';
import { validateAndGetCreationUnixTimeInMicroSeconds } from 'utils/time';
enum MetadataTags {
CREATION_TIME = 'creation_time',
APPLE_CONTENT_IDENTIFIER = 'com.apple.quicktime.content.identifier',
APPLE_LIVE_PHOTO_IDENTIFIER = 'com.apple.quicktime.live-photo.auto',
APPLE_CREATION_DATE = 'com.apple.quicktime.creationdate',
APPLE_LOCATION_ISO = 'com.apple.quicktime.location.ISO6709',
LOCATION = 'location',
}
export function parseFFmpegExtractedMetadata(encodedMetadata: Uint8Array) {
const metadataString = new TextDecoder().decode(encodedMetadata);
const metadataPropertyArray = metadataString.split('\n');
const metadataKeyValueArray = metadataPropertyArray.map((property) =>
property.split('=')
);
const validKeyValuePairs = metadataKeyValueArray.filter(
(keyValueArray) => keyValueArray.length === 2
) as Array<[string, string]>;
const metadataMap = Object.fromEntries(validKeyValuePairs);
const location = parseAppleISOLocation(
metadataMap[MetadataTags.APPLE_LOCATION_ISO] ??
metadataMap[MetadataTags.LOCATION]
);
const creationTime = parseCreationTime(
metadataMap[MetadataTags.APPLE_CREATION_DATE] ??
metadataMap[MetadataTags.CREATION_TIME]
);
const parsedMetadata: ParsedExtractedMetadata = {
creationTime,
location: {
latitude: location.latitude,
longitude: location.longitude,
},
width: null,
height: null,
};
return parsedMetadata;
}
function parseAppleISOLocation(isoLocation: string) {
let location = NULL_LOCATION;
if (isoLocation) {
const [latitude, longitude] = isoLocation
.match(/(\+|-)\d+\.*\d+/g)
.map((x) => parseFloat(x));
location = { latitude, longitude };
}
return location;
}
function parseCreationTime(creationTime: string) {
let dateTime = null;
if (creationTime) {
dateTime = validateAndGetCreationUnixTimeInMicroSeconds(
new Date(creationTime)
);
}
return dateTime;
}
export function splitFilenameAndExtension(filename: string): [string, string] {
const lastDotPosition = filename.lastIndexOf('.');
if (lastDotPosition === -1) return [filename, null];
else
return [
filename.slice(0, lastDotPosition),
filename.slice(lastDotPosition + 1),
];
}

View file

@ -10,7 +10,6 @@ import {
import { decodeLivePhoto } from 'services/livePhotoService';
import { getFileType } from 'services/typeDetectionService';
import { logError } from '@ente/shared/sentry';
import { updateFileCreationDateInEXIF } from 'services/upload/exifService';
import {
TYPE_JPEG,
TYPE_JPG,
@ -28,15 +27,10 @@ import { isArchivedFile, updateMagicMetadata } from 'utils/magicMetadata';
import { CustomError } from '@ente/shared/error';
import ComlinkCryptoWorker from 'utils/comlink/ComlinkCryptoWorker';
import {
deleteFromTrash,
trashFiles,
updateFileMagicMetadata,
} from 'services/fileService';
import { updateFileMagicMetadata } from 'services/fileService';
import isElectron from 'is-electron';
import { isPlaybackPossible } from 'utils/photoFrame';
import { FileTypeInfo } from 'types/upload';
import { moveToHiddenCollection } from 'services/collectionService';
import { getData, LS_KEYS } from '@ente/shared/storage/localStorage';
import { User } from '@ente/shared/user/types';
import { addLogLine, addLocalLog } from '@ente/shared/logging';
@ -85,75 +79,6 @@ export async function getUpdatedEXIFFileForDownload(
}
}
export async function downloadFile(
file: EnteFile,
token?: string,
passwordToken?: string
) {
try {
let fileBlob: Blob;
const fileReader = new FileReader();
const fileURL = await CastDownloadManager.getCachedOriginalFile(
file
)[0];
if (!fileURL) {
fileBlob = await new Response(
await CastDownloadManager.downloadFile(
token,
passwordToken,
file
)
).blob();
} else {
fileBlob = await (await fetch(fileURL)).blob();
}
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const livePhoto = await decodeLivePhoto(file, fileBlob);
const image = new File([livePhoto.image], livePhoto.imageNameTitle);
const imageType = await getFileType(image);
const tempImageURL = URL.createObjectURL(
new Blob([livePhoto.image], { type: imageType.mimeType })
);
const video = new File([livePhoto.video], livePhoto.videoNameTitle);
const videoType = await getFileType(video);
const tempVideoURL = URL.createObjectURL(
new Blob([livePhoto.video], { type: videoType.mimeType })
);
downloadUsingAnchor(tempImageURL, livePhoto.imageNameTitle);
downloadUsingAnchor(tempVideoURL, livePhoto.videoNameTitle);
} else {
const fileType = await getFileType(
new File([fileBlob], file.metadata.title)
);
fileBlob = await new Response(
await getUpdatedEXIFFileForDownload(
fileReader,
file,
fileBlob.stream()
)
).blob();
fileBlob = new Blob([fileBlob], { type: fileType.mimeType });
const tempURL = URL.createObjectURL(fileBlob);
downloadUsingAnchor(tempURL, file.metadata.title);
}
} catch (e) {
logError(e, 'failed to download file');
throw e;
}
}
export function downloadUsingAnchor(link: string, name: string) {
const a = document.createElement('a');
a.style.display = 'none';
a.href = link;
a.download = name;
document.body.appendChild(a);
a.click();
URL.revokeObjectURL(link);
a.remove();
}
export function groupFilesBasedOnCollectionID(files: EnteFile[]) {
const collectionWiseFiles = new Map<number, EnteFile[]>();
for (const file of files) {
@ -544,106 +469,6 @@ export function getUniqueFiles(files: EnteFile[]) {
return uniqueFiles;
}
export async function downloadFiles(
files: EnteFile[],
progressBarUpdater?: {
increaseSuccess: () => void;
increaseFailed: () => void;
isCancelled: () => boolean;
}
) {
for (const file of files) {
try {
if (progressBarUpdater?.isCancelled()) {
return;
}
await downloadFile(file, false);
progressBarUpdater?.increaseSuccess();
} catch (e) {
logError(e, 'download fail for file');
progressBarUpdater?.increaseFailed();
}
}
}
export async function downloadFilesDesktop(
files: EnteFile[],
progressBarUpdater: {
increaseSuccess: () => void;
increaseFailed: () => void;
isCancelled: () => boolean;
},
downloadPath: string
) {
const fileReader = new FileReader();
for (const file of files) {
try {
if (progressBarUpdater?.isCancelled()) {
return;
}
await downloadFileDesktop(fileReader, file, downloadPath);
progressBarUpdater?.increaseSuccess();
} catch (e) {
logError(e, 'download fail for file');
progressBarUpdater?.increaseFailed();
}
}
}
export async function downloadFileDesktop(
fileReader: FileReader,
file: EnteFile,
downloadPath: string
) {
console.log(fileReader, file, downloadPath);
// let fileStream: ReadableStream<Uint8Array>;
// const fileURL = await DownloadManager.getCachedOriginalFile(file)[0];
// if (!fileURL) {
// fileStream = await DownloadManager.downloadFile(file);
// } else {
// fileStream = await fetch(fileURL).then((res) => res.body);
// }
// if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
// const fileBlob = await new Response(updatedFileStream).blob();
// const livePhoto = await decodeLivePhoto(file, fileBlob);
// const imageExportName = getUniqueFileExportName(
// downloadPath,
// livePhoto.imageNameTitle
// );
// const imageStream = generateStreamFromArrayBuffer(livePhoto.image);
// await ElectronFSService.saveMediaFile(
// getFileExportPath(downloadPath, imageExportName),
// imageStream
// );
// try {
// const videoExportName = getUniqueFileExportName(
// downloadPath,
// livePhoto.videoNameTitle
// );
// const videoStream = generateStreamFromArrayBuffer(livePhoto.video);
// await ElectronFSService.saveMediaFile(
// getFileExportPath(downloadPath, videoExportName),
// videoStream
// );
// } catch (e) {
// ElectronFSService.deleteFile(
// getFileExportPath(downloadPath, imageExportName)
// );
// throw e;
// }
// } else {
// const fileExportName = getUniqueFileExportName(
// downloadPath,
// file.metadata.title
// );
// await ElectronFSService.saveMediaFile(
// getFileExportPath(downloadPath, fileExportName),
// updatedFileStream
// );
// }
}
export const isImageOrVideo = (fileType: FILE_TYPE) =>
[FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType);
@ -764,71 +589,6 @@ export const shouldShowAvatar = (file: EnteFile, user: User) => {
}
};
export const handleFileOps = async (
ops: FILE_OPS_TYPE,
files: EnteFile[],
setDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void,
setHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void,
setFixCreationTimeAttributes: (
fixCreationTimeAttributes:
| {
files: EnteFile[];
}
| ((prev: { files: EnteFile[] }) => { files: EnteFile[] })
) => void
) => {
switch (ops) {
case FILE_OPS_TYPE.TRASH:
await deleteFileHelper(files, false, setDeletedFileIds);
break;
case FILE_OPS_TYPE.DELETE_PERMANENTLY:
await deleteFileHelper(files, true, setDeletedFileIds);
break;
case FILE_OPS_TYPE.HIDE:
await hideFilesHelper(files, setHiddenFileIds);
break;
case FILE_OPS_TYPE.DOWNLOAD:
await downloadFiles(files);
break;
case FILE_OPS_TYPE.FIX_TIME:
fixTimeHelper(files, setFixCreationTimeAttributes);
break;
case FILE_OPS_TYPE.ARCHIVE:
await changeFilesVisibility(files, VISIBILITY_STATE.ARCHIVED);
break;
case FILE_OPS_TYPE.UNARCHIVE:
await changeFilesVisibility(files, VISIBILITY_STATE.VISIBLE);
break;
}
};
const deleteFileHelper = async (
selectedFiles: EnteFile[],
permanent: boolean,
setDeletedFileIds: (
deletedFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void
) => {
try {
setDeletedFileIds((deletedFileIds) => {
selectedFiles.forEach((file) => deletedFileIds.add(file.id));
return new Set(deletedFileIds);
});
if (permanent) {
await deleteFromTrash(selectedFiles.map((file) => file.id));
} else {
await trashFiles(selectedFiles);
}
} catch (e) {
setDeletedFileIds(new Set());
throw e;
}
};
export const downloadFileAsBlob = async (
file: EnteFile,
castToken: string
@ -855,33 +615,6 @@ export const downloadFileAsBlob = async (
}
};
const hideFilesHelper = async (
selectedFiles: EnteFile[],
setHiddenFileIds: (
hiddenFileIds: Set<number> | ((prev: Set<number>) => Set<number>)
) => void
) => {
try {
setHiddenFileIds((hiddenFileIds) => {
selectedFiles.forEach((file) => hiddenFileIds.add(file.id));
return new Set(hiddenFileIds);
});
await moveToHiddenCollection(selectedFiles);
} catch (e) {
setHiddenFileIds(new Set());
throw e;
}
};
const fixTimeHelper = async (
selectedFiles: EnteFile[],
setFixCreationTimeAttributes: (fixCreationTimeAttributes: {
files: EnteFile[];
}) => void
) => {
setFixCreationTimeAttributes({ files: selectedFiles });
};
const getFileObjectURLs = (originalBlob: Blob, convertedBlob: Blob) => {
const originalURL = URL.createObjectURL(originalBlob);
const convertedURL = convertedBlob

View file

@ -14,23 +14,6 @@ interface DateComponent<T = number> {
second: T;
}
export function getUnixTimeInMicroSecondsWithDelta(delta: TimeDelta): number {
let currentDate = new Date();
if (delta?.hours) {
currentDate = _addHours(currentDate, delta.hours);
}
if (delta?.days) {
currentDate = _addDays(currentDate, delta.days);
}
if (delta?.months) {
currentDate = _addMonth(currentDate, delta.months);
}
if (delta?.years) {
currentDate = _addYears(currentDate, delta.years);
}
return currentDate.getTime() * 1000;
}
export function validateAndGetCreationUnixTimeInMicroSeconds(dateTime: Date) {
if (!dateTime || isNaN(dateTime.getTime())) {
return null;
@ -46,30 +29,6 @@ export function validateAndGetCreationUnixTimeInMicroSeconds(dateTime: Date) {
}
}
function _addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(date.getDate() + days);
return result;
}
function _addHours(date: Date, hours: number): Date {
const result = new Date(date);
result.setHours(date.getHours() + hours);
return result;
}
function _addMonth(date: Date, months: number) {
const result = new Date(date);
result.setMonth(date.getMonth() + months);
return result;
}
function _addYears(date: Date, years: number) {
const result = new Date(date);
result.setFullYear(date.getFullYear() + years);
return result;
}
/*
generates data component for date in format YYYYMMDD-HHMMSS
*/