diff --git a/package.json b/package.json index 19e75076..93965d4e 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "mkdirp": "^1.0.3", "mousetrap": "^1.6.5", "p-map": "^5.5.0", + "p-retry": "^6.1.0", "pify": "^5.0.0", "pretty-bytes": "^6.0.0", "react": "^18.2.0", diff --git a/src/App.jsx b/src/App.jsx index 0561893b..cbc1d606 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -171,13 +171,19 @@ const App = memo(() => { const [selectedBatchFiles, setSelectedBatchFiles] = useState([]); // Store "working" in a ref so we can avoid race conditions - const workingRef = useRef(working); + const workingRef = useRef(!!working); const setWorking = useCallback((val) => { - workingRef.current = val; - setWorkingState(val); + workingRef.current = !!val; + setWorkingState(val ? { text: val.text, abortController: val.abortController } : undefined); }, []); - useEffect(() => setDocumentTitle({ filePath, working, cutProgress }), [cutProgress, filePath, working]); + const handleAbortWorkingClick = useCallback(() => { + console.log('User clicked abort'); + abortFfmpegs(); // todo use abortcontroller for this also + working?.abortController?.abort(); + }, [working?.abortController]); + + useEffect(() => setDocumentTitle({ filePath, working: working?.text, cutProgress }), [cutProgress, filePath, working?.text]); const zoom = Math.floor(zoomUnrounded); @@ -593,7 +599,7 @@ const App = memo(() => { const subtitleStream = index != null && subtitleStreams.find((s) => s.index === index); if (!subtitleStream || workingRef.current) return; try { - setWorking(i18n.t('Loading subtitle')); + setWorking({ text: i18n.t('Loading subtitle') }); const url = await extractSubtitleTrack(filePath, index); setSubtitlesByStreamId((old) => ({ ...old, [index]: { url, lang: subtitleStream.tags && subtitleStream.tags.language } })); setActiveSubtitleStreamIndex(index); @@ -805,7 +811,7 @@ const App = memo(() => { if (workingRef.current) return; try { - setWorking(i18n.t('Batch converting to supported format')); + setWorking({ text: i18n.t('Batch converting to supported format') }); setCutProgress(0); // eslint-disable-next-line no-restricted-syntax @@ -841,7 +847,7 @@ const App = memo(() => { const html5ifyAndLoadWithPreferences = useCallback(async (cod, fp, speed, hv, ha) => { if (!enableAutoHtml5ify) return; - setWorking(i18n.t('Converting to supported format')); + setWorking({ text: i18n.t('Converting to supported format') }); await html5ifyAndLoad(cod, fp, getConvertToSupportedFormat(speed), hv, ha); }, [enableAutoHtml5ify, setWorking, html5ifyAndLoad, getConvertToSupportedFormat]); @@ -996,7 +1002,7 @@ const App = memo(() => { if (workingRef.current) return; try { setConcatDialogVisible(false); - setWorking(i18n.t('Merging')); + setWorking({ text: i18n.t('Merging') }); const firstPath = paths[0]; if (!firstPath) return; @@ -1069,7 +1075,8 @@ const App = memo(() => { } try { - setWorking(i18n.t('Cleaning up')); + const abortController = new AbortController(); + setWorking({ text: i18n.t('Cleaning up'), abortController }); console.log('Cleaning up files', cleanupChoices2); const pathsToDelete = []; @@ -1077,7 +1084,7 @@ const App = memo(() => { if (cleanupChoices2.trashProjectFile && savedPaths.projectFilePath) pathsToDelete.push(savedPaths.projectFilePath); if (cleanupChoices2.trashSourceFile && savedPaths.sourceFilePath) pathsToDelete.push(savedPaths.sourceFilePath); - await deleteFiles(pathsToDelete, cleanupChoices2.deleteIfTrashFails); + await deleteFiles({ paths: pathsToDelete, deleteIfTrashFails: cleanupChoices2.deleteIfTrashFails, signal: abortController.signal }); } catch (err) { errorToast(i18n.t('Unable to delete file: {{message}}', { message: err.message })); console.error(err); @@ -1151,7 +1158,7 @@ const App = memo(() => { if (workingRef.current) return; try { - setWorking(i18n.t('Exporting')); + setWorking({ text: i18n.t('Exporting') }); // Special segments-to-chapters mode: let chaptersToAdd; @@ -1199,7 +1206,7 @@ const App = memo(() => { if (willMerge) { setCutProgress(0); - setWorking(i18n.t('Merging')); + setWorking({ text: i18n.t('Merging') }); const chapterNames = segmentsToChapters && !invertCutSegments ? segmentsToExport.map((s) => s.name) : undefined; @@ -1235,7 +1242,7 @@ const App = memo(() => { if (exportExtraStreams) { try { setCutProgress(); // If extracting extra streams takes a long time, prevent loader from being stuck at 100% - setWorking(i18n.t('Extracting {{count}} unprocessable tracks', { count: nonCopiedExtraStreams.length })); + setWorking({ text: i18n.t('Extracting {{count}} unprocessable tracks', { count: nonCopiedExtraStreams.length }) }); await extractStreams({ filePath, customOutDir, streams: nonCopiedExtraStreams, enableOverwriteOutput }); notices.push(i18n.t('Unprocessable streams were exported as separate files.')); } catch (err) { @@ -1311,7 +1318,7 @@ const App = memo(() => { if (captureFramesResponse == null) return; try { - setWorking(i18n.t('Extracting frames')); + setWorking({ text: i18n.t('Extracting frames') }); console.log('Extracting frames as images', { segIds, captureFramesResponse }); setCutProgress(0); @@ -1411,7 +1418,7 @@ const App = memo(() => { } } - setWorking(i18n.t('Loading file')); + setWorking({ text: i18n.t('Loading file') }); try { // Need to check if file is actually readable const pathReadAccessErrorCode = await getPathReadAccessError(fp); @@ -1586,7 +1593,7 @@ const App = memo(() => { if (workingRef.current) return; if (filePath === path) return; try { - setWorking(i18n.t('Loading file')); + setWorking({ text: i18n.t('Loading file') }); await userOpenSingleFile({ path }); } catch (err) { handleError(err); @@ -1645,7 +1652,7 @@ const App = memo(() => { if (workingRef.current) return; try { - setWorking(i18n.t('Extracting all streams')); + setWorking({ text: i18n.t('Extracting all streams') }); setStreamsSelectorShown(false); const extractedPaths = await extractStreams({ customOutDir, filePath, streams: mainCopiedStreams, enableOverwriteOutput }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: extractedPaths[0], text: i18n.t('All streams have been extracted as separate files') }); @@ -1685,7 +1692,7 @@ const App = memo(() => { if (workingRef.current) return; try { - setWorking(i18n.t('Converting to supported format')); + setWorking({ text: i18n.t('Converting to supported format') }); await html5ifyAndLoad(customOutDir, filePath, selectedOption, hasVideo, hasAudio); } catch (err) { errorToast(i18n.t('Failed to convert file. Try a different conversion')); @@ -1712,7 +1719,7 @@ const App = memo(() => { const tryFixInvalidDuration = useCallback(async () => { if (!checkFileOpened() || workingRef.current) return; try { - setWorking(i18n.t('Fixing file duration')); + setWorking({ text: i18n.t('Fixing file duration') }); setCutProgress(0); const path = await fixInvalidDuration({ fileFormat, customOutDir, duration, onProgress: setCutProgress }); if (!hideAllNotifications) toast.fire({ icon: 'info', text: i18n.t('Duration has been fixed') }); @@ -1809,7 +1816,7 @@ const App = memo(() => { if (workingRef.current) return; try { - setWorking(i18n.t('Loading file')); + setWorking({ text: i18n.t('Loading file') }); // Import segments for for already opened file const matchingImportProjectType = getImportProjectType(firstFilePath); @@ -2075,7 +2082,7 @@ const App = memo(() => { if (workingRef.current) return; try { - setWorking(i18n.t('Extracting track')); + setWorking({ text: i18n.t('Extracting track') }); // setStreamsSelectorShown(false); const extractedPaths = await extractStreams({ customOutDir, filePath, streams: mainStreams.filter((s) => s.index === index), enableOverwriteOutput }); if (!hideAllNotifications) openDirToast({ icon: 'success', filePath: extractedPaths[0], text: i18n.t('Track has been extracted') }); @@ -2111,7 +2118,7 @@ const App = memo(() => { if (workingRef.current) return; try { - setWorking(i18n.t('Converting to supported format')); + setWorking({ text: i18n.t('Converting to supported format') }); console.log('Trying to create preview'); @@ -2388,7 +2395,7 @@ const App = memo(() => { )} - {working && abortFfmpegs()} />} + {working && } {tunerVisible && setTunerVisible()} />} diff --git a/src/hooks/useSegments.js b/src/hooks/useSegments.js index 00f8bde8..0e12b938 100644 --- a/src/hooks/useSegments.js +++ b/src/hooks/useSegments.js @@ -84,7 +84,7 @@ export default ({ if (!filePath) return; if (workingRef.current) return; try { - setWorking(workingText); + setWorking({ text: workingText }); setCutProgress(0); const newSegments = await fn(); @@ -250,7 +250,7 @@ export default ({ try { const response = await askForAlignSegments(); if (response == null) return; - setWorking(i18n.t('Aligning segments to keyframes')); + setWorking({ text: i18n.t('Aligning segments to keyframes') }); const { mode, startOrEnd } = response; await modifySelectedSegmentTimes(async (segment) => { const newSegment = { ...segment }; diff --git a/src/util.js b/src/util.js index 24be5d2f..5b4e3b02 100644 --- a/src/util.js +++ b/src/util.js @@ -3,6 +3,7 @@ import pMap from 'p-map'; import ky from 'ky'; import prettyBytes from 'pretty-bytes'; import sortBy from 'lodash/sortBy'; +import pRetry from 'p-retry'; import isDev from './isDev'; import Swal, { toast } from './swal'; @@ -234,14 +235,19 @@ export function getHtml5ifiedPath(cod, fp, type) { return getSuffixedOutPath({ customOutDir: cod, filePath: fp, nameSuffix: `${html5ifiedPrefix}${type}.${ext}` }); } -export async function deleteFiles(paths, deleteIfTrashFails) { +export async function deleteFiles({ paths, deleteIfTrashFails, signal }) { const failedToTrashFiles = []; + // const testFail = isDev; + const testFail = false; + // eslint-disable-next-line no-restricted-syntax for (const path of paths) { try { + if (testFail) throw new Error('test trash failure'); // eslint-disable-next-line no-await-in-loop await trashFile(path); + signal.throwIfAborted(); } catch (err) { console.error(err); failedToTrashFiles.push(path); @@ -260,7 +266,19 @@ export async function deleteFiles(paths, deleteIfTrashFails) { if (!value) return; } - await pMap(failedToTrashFiles, async (path) => unlink(path), { concurrency: 1 }); + // Retry because sometimes it fails on windows #272 #1797 + await pMap(failedToTrashFiles, async (path) => { + await pRetry(async () => { + if (testFail) throw new Error('test delete failure'); + await unlink(path); + }, { + retries: 3, + signal, + onFailedAttempt: async () => { + console.warn('Retrying delete', path); + }, + }); + }, { concurrency: 1 }); } export const deleteDispositionValue = 'llc_disposition_remove'; diff --git a/yarn.lock b/yarn.lock index 850908d6..db5d320f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1703,6 +1703,13 @@ __metadata: languageName: node linkType: hard +"@types/retry@npm:0.12.2": + version: 0.12.2 + resolution: "@types/retry@npm:0.12.2" + checksum: e5675035717b39ce4f42f339657cae9637cf0c0051cf54314a6a2c44d38d91f6544be9ddc0280587789b6afd056be5d99dbe3e9f4df68c286c36321579b1bf4a + languageName: node + linkType: hard + "@types/scheduler@npm:*": version: 0.16.2 resolution: "@types/scheduler@npm:0.16.2" @@ -5964,6 +5971,13 @@ __metadata: languageName: node linkType: hard +"is-network-error@npm:^1.0.0": + version: 1.0.0 + resolution: "is-network-error@npm:1.0.0" + checksum: 2ca2b4b2d420015e0237abe28ebf316fcd26a82304b07432abf155759a3bee6895609ac91e692a72ad61b7fc902c3283b2dece61e1ddb05a6257777a8573e468 + languageName: node + linkType: hard + "is-number-object@npm:^1.0.4": version: 1.0.6 resolution: "is-number-object@npm:1.0.6" @@ -6597,6 +6611,7 @@ __metadata: morgan: ^1.10.0 mousetrap: ^1.6.5 p-map: ^5.5.0 + p-retry: ^6.1.0 pify: ^5.0.0 pretty-bytes: ^6.0.0 react: ^18.2.0 @@ -7543,6 +7558,17 @@ __metadata: languageName: node linkType: hard +"p-retry@npm:^6.1.0": + version: 6.1.0 + resolution: "p-retry@npm:6.1.0" + dependencies: + "@types/retry": 0.12.2 + is-network-error: ^1.0.0 + retry: ^0.13.1 + checksum: 1083b2b72672205680f8a736583e31dce5d4ae472996cd06f4a33cd7ea11798d7712c202d253eb8afbdc80abf52f049651989c59f2e2ccca529e6b64d722b1f7 + languageName: node + linkType: hard + "p-try@npm:^1.0.0": version: 1.0.0 resolution: "p-try@npm:1.0.0" @@ -8484,6 +8510,13 @@ __metadata: languageName: node linkType: hard +"retry@npm:^0.13.1": + version: 0.13.1 + resolution: "retry@npm:0.13.1" + checksum: 47c4d5be674f7c13eee4cfe927345023972197dbbdfba5d3af7e461d13b44de1bfd663bfc80d2f601f8ef3fc8164c16dd99655a221921954a65d044a2fc1233b + languageName: node + linkType: hard + "reusify@npm:^1.0.4": version: 1.0.4 resolution: "reusify@npm:1.0.4"