ente/src/services/exportService.ts

361 lines
12 KiB
TypeScript
Raw Normal View History

2021-08-11 08:00:59 +00:00
import { runningInBrowser } from 'utils/common';
import {
getExportPendingFiles,
getExportFailedFiles,
getFilesUploadedAfterLastExport,
dedupe,
getGoogleLikeMetadataFile,
getExportRecordFileUID,
2021-08-11 08:00:59 +00:00
} from 'utils/export';
import { retryAsyncFunction } from 'utils/network';
2021-06-12 17:14:21 +00:00
import { logError } from 'utils/sentry';
2021-07-08 06:59:26 +00:00
import { getData, LS_KEYS } from 'utils/storage/localStorage';
import {
Collection,
getLocalCollections,
getNonEmptyCollections,
} from './collectionService';
2021-03-29 05:15:08 +00:00
import downloadManager from './downloadManager';
2021-08-13 03:19:48 +00:00
import { File, FILE_TYPE, getLocalFiles } from './fileService';
2021-08-12 05:26:17 +00:00
import { decodeMotionPhoto } from './motionPhotoService';
import {
fileNameWithoutExtension,
generateStreamFromArrayBuffer,
} from 'utils/file';
2021-03-29 05:15:08 +00:00
export interface ExportProgress {
2021-07-13 13:39:31 +00:00
current: number;
total: number;
}
export interface ExportStats {
2021-07-13 13:39:31 +00:00
failed: number;
success: number;
2021-07-13 13:39:31 +00:00
}
export interface ExportRecord {
2021-08-11 08:00:59 +00:00
stage: ExportStage;
lastAttemptTimestamp: number;
progress: ExportProgress;
queuedFiles: string[];
2021-07-13 13:39:31 +00:00
exportedFiles: string[];
failedFiles: string[];
}
export enum ExportStage {
INIT,
INPROGRESS,
PAUSED,
2021-08-11 08:00:59 +00:00
FINISHED,
2021-07-13 13:39:31 +00:00
}
enum ExportNotification {
START = 'export started',
IN_PROGRESS = 'export already in progress',
FINISH = 'export finished',
2021-07-05 12:46:20 +00:00
FAILED = 'export failed',
2021-03-31 08:34:00 +00:00
ABORT = 'export aborted',
2021-07-08 06:59:26 +00:00
PAUSE = 'export paused',
2021-08-11 08:00:59 +00:00
UP_TO_DATE = `no new files to export`,
}
2021-07-12 09:15:08 +00:00
enum RecordType {
SUCCESS = 'success',
2021-08-11 08:00:59 +00:00
FAILED = 'failed',
2021-07-12 09:15:08 +00:00
}
2021-07-14 09:16:00 +00:00
export enum ExportType {
NEW,
2021-07-14 09:16:00 +00:00
PENDING,
2021-08-11 08:00:59 +00:00
RETRY_FAILED,
2021-07-14 09:16:00 +00:00
}
2021-07-18 15:01:52 +00:00
const EXPORT_RECORD_FILE_NAME = 'export_status.json';
export const METADATA_FOLDER_NAME = 'metadata';
2021-07-18 15:01:52 +00:00
2021-03-29 05:15:08 +00:00
class ExportService {
ElectronAPIs: any;
2021-05-29 06:27:52 +00:00
2021-08-11 08:00:59 +00:00
private exportInProgress: Promise<{ paused: boolean }> = null;
2021-07-14 07:21:32 +00:00
private recordUpdateInProgress = Promise.resolve();
private stopExport: boolean = false;
private pauseExport: boolean = false;
2021-07-12 09:15:08 +00:00
constructor() {
2021-07-12 09:15:08 +00:00
this.ElectronAPIs = runningInBrowser() && window['ElectronAPIs'];
}
2021-07-07 07:45:55 +00:00
async selectExportDirectory() {
return await this.ElectronAPIs.selectRootDirectory();
}
2021-07-09 03:35:33 +00:00
stopRunningExport() {
this.stopExport = true;
2021-07-07 07:45:55 +00:00
}
2021-07-09 03:35:33 +00:00
pauseRunningExport() {
2021-07-08 06:59:26 +00:00
this.pauseExport = true;
}
2021-08-11 08:00:59 +00:00
async exportFiles(
updateProgress: (progress: ExportProgress) => void,
2021-08-13 02:38:38 +00:00
exportType: ExportType
2021-08-11 08:00:59 +00:00
) {
if (this.exportInProgress) {
this.ElectronAPIs.sendNotification(ExportNotification.IN_PROGRESS);
return this.exportInProgress;
}
2021-07-08 05:45:20 +00:00
this.ElectronAPIs.showOnTray('starting export');
2021-07-14 09:16:00 +00:00
const exportDir = getData(LS_KEYS.EXPORT)?.folder;
if (!exportDir) {
2021-07-12 09:15:08 +00:00
// no-export folder set
return;
}
2021-07-14 09:16:00 +00:00
let filesToExport: File[];
const allFiles = await getLocalFiles();
2021-07-12 09:15:08 +00:00
const collections = await getLocalCollections();
const nonEmptyCollections = getNonEmptyCollections(
collections,
allFiles
);
2021-07-14 09:16:00 +00:00
const exportRecord = await this.getExportRecord(exportDir);
if (exportType === ExportType.NEW) {
2021-08-11 08:00:59 +00:00
filesToExport = await getFilesUploadedAfterLastExport(
allFiles,
2021-08-13 02:38:38 +00:00
exportRecord
2021-08-11 08:00:59 +00:00
);
} else if (exportType === ExportType.RETRY_FAILED) {
2021-07-14 09:16:00 +00:00
filesToExport = await getExportFailedFiles(allFiles, exportRecord);
} else {
filesToExport = await getExportPendingFiles(allFiles, exportRecord);
2021-07-12 09:15:08 +00:00
}
2021-08-11 08:00:59 +00:00
this.exportInProgress = this.fileExporter(
filesToExport,
nonEmptyCollections,
2021-08-11 08:00:59 +00:00
updateProgress,
2021-08-13 02:38:38 +00:00
exportDir
2021-08-11 08:00:59 +00:00
);
const resp = await this.exportInProgress;
this.exportInProgress = null;
return resp;
2021-07-12 09:15:08 +00:00
}
2021-08-11 08:00:59 +00:00
async fileExporter(
files: File[],
collections: Collection[],
updateProgress: (progress: ExportProgress) => void,
2021-08-13 02:38:38 +00:00
dir: string
2021-08-11 08:00:59 +00:00
): Promise<{ paused: boolean }> {
2021-07-12 09:15:08 +00:00
try {
if (!files?.length) {
2021-08-11 08:00:59 +00:00
this.ElectronAPIs.sendNotification(
2021-08-13 02:38:38 +00:00
ExportNotification.UP_TO_DATE
2021-08-11 08:00:59 +00:00
);
return { paused: false };
}
2021-07-12 12:06:21 +00:00
this.stopExport = false;
this.pauseExport = false;
this.addFilesQueuedRecord(dir, files);
const failedFileCount = 0;
2021-07-12 09:15:08 +00:00
2021-07-08 05:45:20 +00:00
this.ElectronAPIs.showOnTray({
2021-08-11 08:00:59 +00:00
export_progress: `0 / ${files.length} files exported`,
2021-07-08 05:45:20 +00:00
});
2021-07-14 09:16:00 +00:00
updateProgress({
2021-08-11 08:00:59 +00:00
current: 0,
total: files.length,
2021-07-14 09:16:00 +00:00
});
2021-07-08 05:45:20 +00:00
this.ElectronAPIs.sendNotification(ExportNotification.START);
const collectionIDMap = new Map<number, string>();
2021-05-29 06:27:52 +00:00
for (const collection of collections) {
2021-08-11 08:00:59 +00:00
const collectionFolderPath = `${dir}/${
collection.id
}_${this.sanitizeName(collection.name)}`;
await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
2021-08-13 02:38:38 +00:00
collectionFolderPath
);
2021-07-18 15:46:51 +00:00
await this.ElectronAPIs.checkExistsAndCreateCollectionDir(
`${collectionFolderPath}/${METADATA_FOLDER_NAME}`
2021-07-18 15:46:51 +00:00
);
collectionIDMap.set(collection.id, collectionFolderPath);
}
2021-05-29 06:27:52 +00:00
for (const [index, file] of files.entries()) {
2021-07-09 03:35:33 +00:00
if (this.stopExport || this.pauseExport) {
2021-07-08 06:59:26 +00:00
if (this.pauseExport) {
this.ElectronAPIs.showOnTray({
2021-08-11 08:00:59 +00:00
export_progress: `${index} / ${files.length} files exported (paused)`,
2021-07-08 06:59:26 +00:00
paused: true,
});
}
2021-03-31 08:34:00 +00:00
break;
}
2021-07-18 15:46:51 +00:00
const collectionPath = collectionIDMap.get(file.collectionID);
2021-07-12 09:15:08 +00:00
try {
2021-07-18 15:46:51 +00:00
await this.downloadAndSave(file, collectionPath);
2021-08-11 08:00:59 +00:00
await this.addFileExportRecord(
dir,
file,
2021-08-13 02:38:38 +00:00
RecordType.SUCCESS
2021-08-11 08:00:59 +00:00
);
2021-07-12 09:15:08 +00:00
} catch (e) {
2021-08-11 08:00:59 +00:00
await this.addFileExportRecord(
dir,
file,
2021-08-13 02:38:38 +00:00
RecordType.FAILED
2021-08-11 08:00:59 +00:00
);
logError(
e,
2021-08-13 02:38:38 +00:00
'download and save failed for file during export'
2021-08-11 08:00:59 +00:00
);
}
2021-07-05 12:46:20 +00:00
this.ElectronAPIs.showOnTray({
2021-08-11 08:00:59 +00:00
export_progress: `${index + 1} / ${
files.length
} files exported`,
2021-07-05 12:46:20 +00:00
});
updateProgress({ current: index + 1, total: files.length });
}
2021-07-09 03:35:33 +00:00
if (this.stopExport) {
2021-08-11 08:00:59 +00:00
this.ElectronAPIs.sendNotification(ExportNotification.ABORT);
2021-07-09 03:35:33 +00:00
this.ElectronAPIs.showOnTray();
2021-07-08 06:59:26 +00:00
} else if (this.pauseExport) {
2021-08-11 08:00:59 +00:00
this.ElectronAPIs.sendNotification(ExportNotification.PAUSE);
2021-07-14 07:21:32 +00:00
return { paused: true };
2021-07-12 09:15:08 +00:00
} else if (failedFileCount > 0) {
2021-08-11 08:00:59 +00:00
this.ElectronAPIs.sendNotification(ExportNotification.FAILED);
2021-07-05 12:46:20 +00:00
this.ElectronAPIs.showOnTray({
2021-08-11 08:00:59 +00:00
retry_export: `export failed - retry export`,
2021-07-05 12:46:20 +00:00
});
} else {
2021-08-11 08:00:59 +00:00
this.ElectronAPIs.sendNotification(ExportNotification.FINISH);
2021-07-05 12:46:20 +00:00
this.ElectronAPIs.showOnTray();
}
2021-07-14 07:21:32 +00:00
return { paused: false };
} catch (e) {
logError(e, 'export failed ');
2021-03-29 05:15:08 +00:00
}
}
async addFilesQueuedRecord(folder: string, files: File[]) {
const exportRecord = await this.getExportRecord(folder);
exportRecord.queuedFiles = files.map(getExportRecordFileUID);
await this.updateExportRecord(exportRecord, folder);
}
2021-03-29 11:29:19 +00:00
async addFileExportRecord(folder: string, file: File, type: RecordType) {
const fileUID = getExportRecordFileUID(file);
2021-07-13 14:42:59 +00:00
const exportRecord = await this.getExportRecord(folder);
2021-08-11 08:00:59 +00:00
exportRecord.queuedFiles = exportRecord.queuedFiles.filter(
2021-08-13 02:38:38 +00:00
(queuedFilesUID) => queuedFilesUID !== fileUID
2021-08-11 08:00:59 +00:00
);
2021-07-13 13:39:31 +00:00
if (type === RecordType.SUCCESS) {
if (!exportRecord.exportedFiles) {
exportRecord.exportedFiles = [];
}
exportRecord.exportedFiles.push(fileUID);
2021-08-11 08:00:59 +00:00
exportRecord.failedFiles &&
(exportRecord.failedFiles = exportRecord.failedFiles.filter(
2021-08-13 02:38:38 +00:00
(FailedFileUID) => FailedFileUID !== fileUID
2021-08-11 08:00:59 +00:00
));
2021-07-13 13:39:31 +00:00
} else {
if (!exportRecord.failedFiles) {
exportRecord.failedFiles = [];
}
2021-07-14 04:33:28 +00:00
if (!exportRecord.failedFiles.find((x) => x === fileUID)) {
exportRecord.failedFiles.push(fileUID);
}
2021-07-13 13:39:31 +00:00
}
2021-07-14 12:55:05 +00:00
exportRecord.exportedFiles = dedupe(exportRecord.exportedFiles);
exportRecord.queuedFiles = dedupe(exportRecord.queuedFiles);
exportRecord.failedFiles = dedupe(exportRecord.failedFiles);
2021-07-13 14:42:59 +00:00
await this.updateExportRecord(exportRecord, folder);
2021-07-13 13:39:31 +00:00
}
2021-07-13 14:42:59 +00:00
async updateExportRecord(newData: Record<string, any>, folder?: string) {
2021-07-14 04:33:28 +00:00
await this.recordUpdateInProgress;
this.recordUpdateInProgress = (async () => {
try {
if (!folder) {
folder = getData(LS_KEYS.EXPORT)?.folder;
}
const exportRecord = await this.getExportRecord(folder);
const newRecord = { ...exportRecord, ...newData };
2021-08-11 08:00:59 +00:00
await this.ElectronAPIs.setExportRecord(
`${folder}/${EXPORT_RECORD_FILE_NAME}`,
2021-08-13 02:38:38 +00:00
JSON.stringify(newRecord, null, 2)
2021-08-11 08:00:59 +00:00
);
} catch (e) {
2021-07-14 13:14:38 +00:00
logError(e, 'error updating Export Record');
2021-07-13 14:42:59 +00:00
}
})();
2021-07-13 13:39:31 +00:00
}
async getExportRecord(folder?: string): Promise<ExportRecord> {
2021-07-14 04:33:28 +00:00
try {
await this.recordUpdateInProgress;
2021-07-14 04:33:28 +00:00
if (!folder) {
folder = getData(LS_KEYS.EXPORT)?.folder;
2021-07-14 04:33:28 +00:00
}
2021-08-11 08:00:59 +00:00
const recordFile = await this.ElectronAPIs.getExportRecord(
`${folder}/${EXPORT_RECORD_FILE_NAME}`
2021-08-11 08:00:59 +00:00
);
2021-07-14 04:33:28 +00:00
if (recordFile) {
return JSON.parse(recordFile);
2021-07-14 13:14:38 +00:00
} else {
return {} as ExportRecord;
2021-07-14 04:33:28 +00:00
}
} catch (e) {
2021-07-14 13:14:38 +00:00
logError(e, 'export Record JSON parsing failed ');
2021-07-13 14:42:59 +00:00
}
2021-07-13 13:39:31 +00:00
}
2021-08-11 08:00:59 +00:00
async downloadAndSave(file: File, collectionPath: string) {
const uid = `${file.id}_${this.sanitizeName(file.metadata.title)}`;
const fileStream = await retryAsyncFunction(() =>
2021-08-13 02:38:38 +00:00
downloadManager.downloadFile(file)
2021-08-11 08:00:59 +00:00
);
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
2021-08-12 05:26:17 +00:00
this.exportMotionPhoto(fileStream, file, collectionPath);
} else {
this.saveMediaFile(collectionPath, uid, fileStream);
this.saveMetadataFile(collectionPath, uid, file.metadata);
2021-08-12 05:26:17 +00:00
}
2021-03-29 11:29:19 +00:00
}
2021-05-29 06:27:52 +00:00
2021-08-12 05:26:17 +00:00
private async exportMotionPhoto(
fileStream: ReadableStream<any>,
file: File,
2021-08-13 02:38:38 +00:00
collectionPath: string
2021-08-12 05:26:17 +00:00
) {
const fileBlob = await new Response(fileStream).blob();
const originalName = fileNameWithoutExtension(file.metadata.title);
const motionPhoto = await decodeMotionPhoto(fileBlob, originalName);
const imageStream = generateStreamFromArrayBuffer(motionPhoto.image);
const imageUID = `${file.id}_${motionPhoto.imageNameTitle}`;
this.saveMediaFile(collectionPath, imageUID, imageStream);
this.saveMetadataFile(collectionPath, imageUID, file.metadata);
2021-08-12 05:26:17 +00:00
const videoStream = generateStreamFromArrayBuffer(motionPhoto.video);
const videoUID = `${file.id}_${motionPhoto.videoNameTitle}`;
this.saveMediaFile(collectionPath, videoUID, videoStream);
this.saveMetadataFile(collectionPath, videoUID, file.metadata);
}
private saveMediaFile(collectionPath, uid, fileStream) {
this.ElectronAPIs.saveStreamToDisk(
`${collectionPath}/${uid}`,
fileStream
);
}
private saveMetadataFile(collectionPath, uid, metadata) {
this.ElectronAPIs.saveFileToDisk(
`${collectionPath}/${METADATA_FOLDER_NAME}/${uid}.json`,
getGoogleLikeMetadataFile(uid, metadata)
);
2021-08-12 05:26:17 +00:00
}
2021-04-04 08:23:00 +00:00
private sanitizeName(name) {
return name.replaceAll('/', '_').replaceAll(' ', '_');
}
isExportInProgress = () => {
return this.exportInProgress !== null;
2021-08-11 08:00:59 +00:00
};
2021-03-29 05:15:08 +00:00
}
export default new ExportService();