diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index f168fa85..cdc877e1 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -17,7 +17,7 @@ import { SweetAlertOptions } from 'sweetalert2'; import theme from './theme'; import useTimelineScroll from './hooks/useTimelineScroll'; import useUserSettingsRoot from './hooks/useUserSettingsRoot'; -import useFfmpegOperations from './hooks/useFfmpegOperations'; +import useFfmpegOperations, { OutputNotWritableError } from './hooks/useFfmpegOperations'; import useKeyframes from './hooks/useKeyframes'; import useWaveform from './hooks/useWaveform'; import useKeyboard from './hooks/useKeyboard'; @@ -71,12 +71,13 @@ import { calcShouldShowWaveform, calcShouldShowKeyframes, mediaSourceQualities, isExecaError, getStdioString, isMuxNotSupported, getDownloadMediaOutPath, + isAbortedError, } from './util'; import { toast, errorToast, showPlaybackFailedMessage } from './swal'; import { adjustRate } from './util/rate-calculator'; import { askExtractFramesAsImages } from './dialogs/extractFrames'; import { askForHtml5ifySpeed } from './dialogs/html5ify'; -import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType } from './dialogs'; +import { askForOutDir, askForImportChapters, promptTimecode, askForFileOpenAction, confirmExtractAllStreamsDialog, showCleanupFilesDialog, showDiskFull, showExportFailedDialog, showConcatFailedDialog, openYouTubeChaptersDialog, showRefuseToOverwrite, openDirToast, openExportFinishedToast, openConcatFinishedToast, showOpenDialog, showMuxNotSupported, promptDownloadMediaUrl, CleanupChoicesType, showOutputNotWritable } from './dialogs'; import { openSendReportDialog } from './reporting'; import { fallbackLng } from './i18n'; import { findSegmentsAtCursor, sortSegments, convertSegmentsToChapters, hasAnySegmentOverlap, isDurationValid, playOnlyCurrentSegment, getSegmentTags } from './segments'; @@ -863,16 +864,11 @@ function App() { openConcatFinishedToast({ filePath: outPath, notices, warnings }); } } catch (err) { + if (err instanceof DirectoryAccessDeclinedError || isAbortedError(err)) return; + showOsNotification(i18n.t('Failed to merge')); - if (err instanceof DirectoryAccessDeclinedError) return; - if (isExecaError(err)) { - if (err.killed) { - // assume execa killed (aborted by user) - return; - } - console.log('stdout:', getStdioString(err.stdout)); console.error('stderr:', getStdioString(err.stderr)); @@ -884,12 +880,15 @@ function App() { showMuxNotSupported(); return; } - const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters }; - handleConcatFailed(err, reportState); + } + + if (err instanceof OutputNotWritableError) { + showOutputNotWritable(); return; } - handleError(err); + const reportState = { includeAllStreams, streams, outFormat, outFileName, segmentsToChapters }; + handleConcatFailed(err, reportState); } finally { setWorking(undefined); setCutProgress(undefined); @@ -1105,17 +1104,14 @@ function App() { if (cleanupChoices.cleanupAfterExport) await cleanupFilesWithDialog(); } catch (err) { - if (isExecaError(err)) { - if (err.killed) { - // assume execa killed (aborted by user) - return; - } + if (isAbortedError(err)) return; + showOsNotification(i18n.t('Failed to export')); + + if (isExecaError(err)) { console.log('stdout:', getStdioString(err.stdout)); console.error('stderr:', getStdioString(err.stderr)); - showOsNotification(i18n.t('Failed to export')); - if (isOutOfSpaceError(err)) { showDiskFull(); return; @@ -1124,12 +1120,14 @@ function App() { showMuxNotSupported(); return; } - handleExportFailed(err); + } + + if (err instanceof OutputNotWritableError) { + showOutputNotWritable(); return; } - showOsNotification(i18n.t('Failed to export')); - handleError(err); + handleExportFailed(err); } finally { setWorking(undefined); setCutProgress(undefined); @@ -1258,7 +1256,7 @@ function App() { // then try to open project from source file dir const sameDirEdlFilePath = getEdlFilePath(fp); - // MAS only allows fs.stat (fs-extra.exists) if we don't have access to input dir yet, so check first if the file exists, + // MAS only allows fs.access (fs-extra.exists) if we don't have access to input dir yet, so check first if the file exists, // so we don't need to annoy the user by asking for permission if the project file doesn't exist if (await exists(sameDirEdlFilePath)) { // Ok, the file exists. now we have to ask the user, because we need to read that file diff --git a/src/renderer/src/dialogs/index.tsx b/src/renderer/src/dialogs/index.tsx index db70bb65..5287567f 100644 --- a/src/renderer/src/dialogs/index.tsx +++ b/src/renderer/src/dialogs/index.tsx @@ -168,6 +168,13 @@ export async function showMuxNotSupported() { }); } +export async function showOutputNotWritable() { + await Swal.fire({ + icon: 'error', + text: i18n.t('You are not allowed to write the output file. This probably means that the file already exists with the wrong permissions, or you don\'t have write permissions to the output folder.'), + }); +} + export async function showRefuseToOverwrite() { await Swal.fire({ icon: 'warning', diff --git a/src/renderer/src/hooks/useFfmpegOperations.ts b/src/renderer/src/hooks/useFfmpegOperations.ts index ecf5a05e..fa1dc69e 100644 --- a/src/renderer/src/hooks/useFfmpegOperations.ts +++ b/src/renderer/src/hooks/useFfmpegOperations.ts @@ -14,8 +14,15 @@ import { AvoidNegativeTs, Html5ifyMode } from '../../../../types'; import { AllFilesMeta, Chapter, CopyfileStreams, CustomTagsByFile, ParamsByStreamId, SegmentToExport } from '../types'; const { join, resolve, dirname } = window.require('path'); -const { pathExists } = window.require('fs-extra'); -const { writeFile, mkdir } = window.require('fs/promises'); +const { writeFile, mkdir, access, constants: { F_OK, W_OK } } = window.require('fs/promises'); + + +export class OutputNotWritableError extends Error { + constructor() { + super(); + this.name = 'OutputNotWritableError'; + } +} async function writeChaptersFfmetadata(outDir: string, chapters: Chapter[] | undefined) { if (!chapters || chapters.length === 0) return undefined; @@ -60,6 +67,15 @@ async function tryDeleteFiles(paths: string[]) { return pMap(paths, (path) => unlinkWithRetry(path).catch((err) => console.error('Failed to delete', path, err)), { concurrency: 5 }); } +async function pathExists(path: string) { + try { + await access(path, F_OK); + return true; + } catch { + return false; + } +} + function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, treatOutputFileModifiedTimeAsStart, needSmartCut, enableOverwriteOutput, outputPlaybackRate, cutFromAdjustmentFrames, appendLastCommandsLog, smartCutCustomBitrate }: { filePath: string | undefined, treatInputFileModifiedTimeAsStart: boolean | null | undefined, @@ -74,9 +90,20 @@ function useFfmpegOperations({ filePath, treatInputFileModifiedTimeAsStart, trea const appendFfmpegCommandLog = useCallback((args: string[]) => appendLastCommandsLog(getFfCommandLine('ffmpeg', args)), [appendLastCommandsLog]); const shouldSkipExistingFile = useCallback(async (path: string) => { - const skip = !enableOverwriteOutput && await pathExists(path); - if (skip) console.log('Not overwriting existing file', path); - return skip; + const fileExists = await pathExists(path); + + // If output file exists, check that it is writable, so we can inform user if it's not (or else ffmpeg will fail with "Permission denied") + // this seems to sometimes happen on Windows, not sure why. + if (fileExists) { + try { + await access(path, W_OK); + } catch { + throw new OutputNotWritableError(); + } + } + const shouldSkip = !enableOverwriteOutput && fileExists; + if (shouldSkip) console.log('Not overwriting existing file', path); + return shouldSkip; }, [enableOverwriteOutput]); const getOutputPlaybackRateArgs = useCallback(() => (outputPlaybackRate !== 1 ? ['-itsscale', String(1 / outputPlaybackRate)] : []), [outputPlaybackRate]); diff --git a/src/renderer/src/util.ts b/src/renderer/src/util.ts index 44bd435d..d5c6d6b4 100644 --- a/src/renderer/src/util.ts +++ b/src/renderer/src/util.ts @@ -318,6 +318,9 @@ export function isExecaError(err: unknown): err is InvariantExecaError { return err instanceof Error && 'stdout' in err && 'stderr' in err; } +// execa killed (aborted by user) +export const isAbortedError = (err: unknown) => isExecaError(err) && err.killed; + export const getStdioString = (stdio: string | Buffer | undefined) => (stdio instanceof Buffer ? stdio.toString('utf8') : stdio); // A bit hacky but it works, unless someone has a file called "No space left on device" ( ͡° ͜ʖ ͡°)