From 71c583c6fbf108b9b1ed68e50ee873cd74660918 Mon Sep 17 00:00:00 2001 From: KerballOne Date: Fri, 7 Aug 2026 11:34:38 -0600 Subject: [PATCH 1/6] [Feature Request]: Add yt-dlp intergration --- src/constants.js | 8 +- src/main/download.js | 112 ++++++++++ src/main/index.js | 91 ++++++++ src/preload/interface.js | 28 +++ .../components/ExternalDownloaderSettings.vue | 202 ++++++++++++++++++ .../WatchVideoInfo/WatchVideoInfo.css | 19 ++ .../WatchVideoInfo/WatchVideoInfo.vue | 135 +++++++++++- src/renderer/main.js | 2 + src/renderer/store/modules/settings.js | 11 + src/renderer/views/Settings/Settings.vue | 32 ++- static/locales/en-US.yaml | 37 ++++ 11 files changed, 673 insertions(+), 4 deletions(-) create mode 100644 src/main/download.js create mode 100644 src/renderer/components/ExternalDownloaderSettings.vue diff --git a/src/constants.js b/src/constants.js index e7d9c8c99b646..fee9965b66a80 100644 --- a/src/constants.js +++ b/src/constants.js @@ -46,8 +46,14 @@ const IpcChannels = { CHOOSE_DEFAULT_FOLDER: 'choose-default-folder', WRITE_TO_DEFAULT_FOLDER: 'write-to-default-folder', + CHOOSE_YTDLP_OUTPUT_DIRECTORY: 'choose-ytdlp-output-directory', + CHOOSE_YTDLP_EXECUTABLE: 'choose-ytdlp-executable', + CHOOSE_FFMPEG_EXECUTABLE: 'choose-ffmpeg-executable', + OPEN_IN_EXTERNAL_PLAYER: 'open-in-external-player', - OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result' + OPEN_IN_EXTERNAL_PLAYER_RESULT: 'open-in-external-player-result', + + DOWNLOAD_VIDEO: 'download-video' } const DBActions = { diff --git a/src/main/download.js b/src/main/download.js new file mode 100644 index 0000000000000..d6682e725576c --- /dev/null +++ b/src/main/download.js @@ -0,0 +1,112 @@ +import { spawn } from 'node:child_process' +import { settings } from '../datastores/handlers/base' +import { isFreeTubeUrl } from './utils' + +const ID_REGEX = /^[\w-]+$/ + +/** + * @typedef {'ok' | 'invalid' | 'not-configured' | 'error'} DownloadVideoResult + */ + +/** + * @param {import('electron').IpcMainInvokeEvent} event + * @param {{ videoId: string, mode: 'video' | 'audio', startTime: number | null | undefined, endTime: number | null | undefined }} payload + * @returns {Promise} + */ +export async function handleDownloadVideo(event, payload) { + if (!isFreeTubeUrl(event.senderFrame.url) || !event.sender.isFocused()) { + return 'invalid' + } + + const { videoId, mode, startTime, endTime } = payload ?? {} + + if (typeof videoId !== 'string' || videoId.length !== 11 || !ID_REGEX.test(videoId)) { + return 'invalid' + } + + if (mode !== 'video' && mode !== 'audio') { + return 'invalid' + } + + const hasValidStartTime = typeof startTime === 'number' && startTime >= 0 + const hasValidEndTime = typeof endTime === 'number' && endTime > 0 + + /** @type {string} */ + const executable = (await settings._findOne('ytdlpExecutable'))?.value || '' + + if (executable.length === 0) { + return 'not-configured' + } + + /** @type {string} */ + const outputDirectory = (await settings._findOne('ytdlpOutputDirectory'))?.value || '' + + /** @type {string} */ + const ffmpegExecutable = (await settings._findOne('ffmpegExecutable'))?.value || '' + + const customArgsSettingId = mode === 'audio' ? 'ytdlpAudioCustomArgs' : 'ytdlpVideoCustomArgs' + + /** @type {string} */ + const customArgs = (await settings._findOne(customArgsSettingId))?.value || '' + + const videoUrl = `https://www.youtube.com/watch?v=${videoId}` + + const args = [] + + if (outputDirectory.length > 0) { + args.push('-o', `${outputDirectory}/%(title)s.%(ext)s`) + } + + if (ffmpegExecutable.length > 0) { + args.push('--ffmpeg-location', ffmpegExecutable) + } + + if (hasValidStartTime || hasValidEndTime) { + const start = hasValidStartTime ? startTime : 0 + const end = hasValidEndTime ? endTime : 'inf' + args.push('--download-sections', `*${start}-${end}`) + } + + if (mode === 'audio') { + args.push('-x') + } + + if (customArgs.trim().length > 0) { + args.push(...customArgs.trim().split(/\s+/)) + } + + args.push(videoUrl) + + return new Promise((resolve) => { + let child + + if (process.platform === 'win32') { + // cmd /k only strips quotes if they enclose the whole string, so wrap it twice + const innerCommand = [executable, ...args].map(part => `"${part.replaceAll('"', '""')}"`).join(' ') + child = spawn('cmd.exe', ['/c', 'start', '""', '/wait', 'cmd.exe', '/k', `"${innerCommand}"`], { + detached: true, + stdio: 'ignore', + windowsVerbatimArguments: true + }) + } else if (process.platform === 'darwin') { + child = spawn('open', ['-a', 'Terminal', '-n', '--args', executable, ...args], { + detached: true, + stdio: 'ignore' + }) + } else { + child = spawn('x-terminal-emulator', ['-e', executable, ...args], { + detached: true, + stdio: 'ignore' + }) + } + + child.once('error', () => { + resolve('error') + }) + + child.once('spawn', () => { + child.unref() + resolve('ok') + }) + }) +} diff --git a/src/main/index.js b/src/main/index.js index 5ca4678343539..a717f4537f9ec 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -28,6 +28,7 @@ import contextMenu from 'electron-context-menu' import packageDetails from '../../package.json' import { handleOpenInExternalPlayer } from './externalPlayer' +import { handleDownloadVideo } from './download' import { generatePoToken } from './poTokenGenerator' import { isFreeTubeUrl } from './utils' @@ -1433,6 +1434,94 @@ function runApp() { return result.filePaths[0] } + /** + * @param {string} settingId + * @param {string} value + */ + async function persistAndSyncSetting(settingId, value) { + await baseHandlers.settings.upsert(settingId, value) + + const syncPayload = { + event: SyncEvents.GENERAL.UPSERT, + data: { + _id: settingId, + value + } + } + + BrowserWindow.getAllWindows().forEach((window) => { + if (isFreeTubeUrl(window.webContents.getURL())) { + window.webContents.send(IpcChannels.SYNC_SETTINGS, syncPayload) + } + }) + } + + ipcMain.on(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY, async (event) => { + if (!isFreeTubeUrl(event.senderFrame.url)) { + return + } + + const currentPath = (await baseHandlers.settings._findOne('ytdlpOutputDirectory'))?.value + + const dialogOptions = { + defaultPath: typeof currentPath === 'string' && currentPath.length > 0 ? currentPath : app.getPath('downloads'), + properties: ['openDirectory'] + } + + const window = BrowserWindow.fromWebContents(event.sender) + const result = window + ? await dialog.showOpenDialog(window, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled) { + return + } + + await persistAndSyncSetting('ytdlpOutputDirectory', result.filePaths[0]) + }) + + /** + * @param {import('electron').IpcMainEvent} event + * @param {string} settingId + */ + async function chooseExecutable(event, settingId) { + if (!isFreeTubeUrl(event.senderFrame.url)) { + return + } + + const currentPath = (await baseHandlers.settings._findOne(settingId))?.value + + const dialogOptions = { + defaultPath: typeof currentPath === 'string' && currentPath.length > 0 ? currentPath : undefined, + properties: ['openFile'], + ...(process.platform === 'win32' && { + filters: [ + { name: 'Executables', extensions: ['exe'] }, + { name: 'All Files', extensions: ['*'] } + ] + }) + } + + const window = BrowserWindow.fromWebContents(event.sender) + const result = window + ? await dialog.showOpenDialog(window, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled) { + return + } + + await persistAndSyncSetting(settingId, result.filePaths[0]) + } + + ipcMain.on(IpcChannels.CHOOSE_YTDLP_EXECUTABLE, async (event) => { + await chooseExecutable(event, 'ytdlpExecutable') + }) + + ipcMain.on(IpcChannels.CHOOSE_FFMPEG_EXECUTABLE, async (event) => { + await chooseExecutable(event, 'ffmpegExecutable') + }) + ipcMain.on(IpcChannels.CHOOSE_DEFAULT_FOLDER, async (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return @@ -1572,6 +1661,8 @@ function runApp() { ipcMain.on(IpcChannels.OPEN_IN_EXTERNAL_PLAYER, handleOpenInExternalPlayer) + ipcMain.handle(IpcChannels.DOWNLOAD_VIDEO, handleDownloadVideo) + ipcMain.handle(IpcChannels.GET_REPLACE_HTTP_CACHE, (event) => { if (isFreeTubeUrl(event.senderFrame.url)) { return replaceHttpCache diff --git a/src/preload/interface.js b/src/preload/interface.js index b32286c8c07c5..450a3d5c3aab7 100644 --- a/src/preload/interface.js +++ b/src/preload/interface.js @@ -131,6 +131,18 @@ export default { ipcRenderer.send(IpcChannels.CHOOSE_DEFAULT_FOLDER) }, + chooseYtdlpOutputDirectory: () => { + ipcRenderer.send(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY) + }, + + chooseYtdlpExecutable: () => { + ipcRenderer.send(IpcChannels.CHOOSE_YTDLP_EXECUTABLE) + }, + + chooseFfmpegExecutable: () => { + ipcRenderer.send(IpcChannels.CHOOSE_FFMPEG_EXECUTABLE) + }, + /** * @param {string} filename * @param {ArrayBuffer} contents @@ -168,6 +180,22 @@ export default { }) }, + /** + * @param {string} videoId + * @param {'video' | 'audio'} mode + * @param {number | null} [startTime] + * @param {number | null} [endTime] + * @returns {Promise} + */ + downloadVideo: (videoId, mode, startTime, endTime) => { + // require the user to have interacted with the page recently + if (navigator.userActivation.isActive) { + return ipcRenderer.invoke(IpcChannels.DOWNLOAD_VIDEO, { videoId, mode, startTime, endTime }) + } + + return Promise.resolve('invalid') + }, + /** * @param {number} factor */ diff --git a/src/renderer/components/ExternalDownloaderSettings.vue b/src/renderer/components/ExternalDownloaderSettings.vue new file mode 100644 index 0000000000000..a2d6a55251b2b --- /dev/null +++ b/src/renderer/components/ExternalDownloaderSettings.vue @@ -0,0 +1,202 @@ + + + + + diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css index 61b26e46c2ef7..c575416576b6c 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css @@ -139,3 +139,22 @@ inset-inline: 0 auto; } } + +.downloadOptions { + padding-inline: 12px; + padding-block-start: 12px; + min-inline-size: 220px; +} + +.downloadButtons { + display: flex; + flex-direction: column; + padding: 12px; + max-inline-size: min-content; + min-inline-size: 150px; +} + +.downloadButtons .action { + padding: 6px; + white-space: initial; +} diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue index 3a2b430bc2f13..a56720927cbee 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue @@ -111,6 +111,56 @@ theme="secondary" @click="handleExternalPlayer" /> + +
+ + + + + + + + + +
+
+ + +
+
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' -import { computed, onMounted } from 'vue' +import { computed, onMounted, ref } from 'vue' import { useI18n } from 'vue-i18n' +import { useRouter } from 'vue-router' +import FtButton from '../FtButton/FtButton.vue' import FtCard from '../ft-card/ft-card.vue' +import FtFlexBox from '../ft-flex-box/ft-flex-box.vue' import FtIconButton from '../FtIconButton/FtIconButton.vue' +import FtInput from '../FtInput/FtInput.vue' import FtShareButton from '../FtShareButton/FtShareButton.vue' import FtSubscribeButton from '../FtSubscribeButton/FtSubscribeButton.vue' +import FtToggleSwitch from '../FtToggleSwitch/FtToggleSwitch.vue' import store from '../../store' -import { formatNumber, showToast } from '../../helpers/utils' +import { formatDurationAsTimestamp, formatNumber, showToast } from '../../helpers/utils' const props = defineProps({ id: { @@ -246,6 +301,7 @@ const emit = defineEmits([ const USING_ELECTRON = process.env.IS_ELECTRON const { locale, t } = useI18n() +const router = useRouter() /** @type {import('vue').ComputedRef} */ const hideSharingActions = computed(() => store.getters.getHideSharingActions) @@ -392,6 +448,81 @@ function handleExternalPlayer() { } } +const downloadIncludeTimestamp = ref(false) +const downloadStartTime = ref('0:00') +const downloadEndTime = ref('') + +function updateDownloadIncludeTimestamp() { + downloadIncludeTimestamp.value = !downloadIncludeTimestamp.value + + if (downloadIncludeTimestamp.value) { + downloadStartTime.value = formatDurationAsTimestamp(Math.trunc(props.getTimestamp())) + downloadEndTime.value = formatDurationAsTimestamp(Math.trunc(props.lengthSeconds)) + } +} + +/** + * @param {string} value + */ +function updateDownloadStartTime(value) { + downloadStartTime.value = value +} + +/** + * @param {string} value + */ +function updateDownloadEndTime(value) { + downloadEndTime.value = value +} + +/** + * @param {string} value + * @returns {number | null} + */ +function parseTimestampToSeconds(value) { + const trimmed = value.trim() + if (trimmed === '') { + return null + } + + const parts = trimmed.split(':') + if (parts.length < 2 || parts.length > 3 || parts.some(part => !/^\d+$/.test(part))) { + return null + } + + const numbers = parts.map(Number) + const [hours, minutes, seconds] = numbers.length === 3 ? numbers : [0, ...numbers] + + return (hours * 3600) + (minutes * 60) + seconds +} + +/** + * @param {'video' | 'audio'} mode + */ +async function handleDownload(mode) { + if (!process.env.IS_ELECTRON) { + return + } + + const startTime = downloadIncludeTimestamp.value ? parseTimestampToSeconds(downloadStartTime.value) : null + const endTime = downloadIncludeTimestamp.value ? parseTimestampToSeconds(downloadEndTime.value) : null + + const result = await window.ftElectron.downloadVideo(props.id, mode, startTime, endTime) + + switch (result) { + case 'ok': + showToast(mode === 'audio' + ? t('Video.Audio download has started') + : t('Video.Video download has started')) + break + case 'not-configured': + case 'error': + showToast(t('Video.Video download failed, please configure the External Downloader Settings')) + router.push({ path: '/settings', query: { section: 'external-downloader' } }) + break + } +} + onMounted(() => { if (process.env.IS_ELECTRON || 'mediaSession' in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ diff --git a/src/renderer/main.js b/src/renderer/main.js index 763d6a0875973..8bfd3b19d57c4 100644 --- a/src/renderer/main.js +++ b/src/renderer/main.js @@ -67,6 +67,7 @@ import { faGlobe, faGrip, faHashtag, + faHeadphones, faHeart, faHistory, faImages, @@ -192,6 +193,7 @@ library.add( faGlobe, faGrip, faHashtag, + faHeadphones, faHeart, faHistory, faImages, diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index 694be8ff0be32..8fcb8e242c2b5 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -189,6 +189,11 @@ const state = { externalPlayerIgnoreDefaultArgs: false, externalPlayerCustomArgs: '[]', showAddedExternalPlayerCustomArgs: true, + ytdlpExecutable: '', + ffmpegExecutable: '', + ytdlpOutputDirectory: '', + ytdlpVideoCustomArgs: '', + ytdlpAudioCustomArgs: '', expandSideBar: false, hideActiveSubscriptions: false, hideChannelCommunity: false, @@ -440,6 +445,12 @@ export const NON_TRANSFERABLE_SETTINGS = new Set([ 'externalPlayerIgnoreDefaultArgs', 'externalPlayerCustomArgs', 'showAddedExternalPlayerCustomArgs', + // ExternalDownloaderSettings + 'ytdlpExecutable', + 'ffmpegExecutable', + 'ytdlpOutputDirectory', + 'ytdlpVideoCustomArgs', + 'ytdlpAudioCustomArgs', // Others 'disableSmoothScrolling', 'hideToTrayOnMinimize', diff --git a/src/renderer/views/Settings/Settings.vue b/src/renderer/views/Settings/Settings.vue index 927361de33235..f2a3331bd3982 100644 --- a/src/renderer/views/Settings/Settings.vue +++ b/src/renderer/views/Settings/Settings.vue @@ -61,13 +61,15 @@ From ecb070778c7477601ea0213f779a885af2d4b8ff Mon Sep 17 00:00:00 2001 From: KerballOne Date: Sat, 8 Aug 2026 14:10:28 -0600 Subject: [PATCH 3/6] Reworked the settings. Download buttons into icon links, Choose buttons into folder icon buttons. Version check automatic on path change, throttled. Redone layout to make compact. --- .../components/ExternalDownloaderSettings.vue | 220 +++++++++++------- .../WatchVideoInfo/WatchVideoInfo.vue | 2 +- src/renderer/main.js | 2 + 3 files changed, 139 insertions(+), 85 deletions(-) diff --git a/src/renderer/components/ExternalDownloaderSettings.vue b/src/renderer/components/ExternalDownloaderSettings.vue index d0f88fa4895af..3859b46c7b21c 100644 --- a/src/renderer/components/ExternalDownloaderSettings.vue +++ b/src/renderer/components/ExternalDownloaderSettings.vue @@ -2,81 +2,108 @@ - -

- {{ versionsText }} -

+ - - -
- - +
+

+ {{ t('Settings.External Downloader Settings.yt-dlp Executable Path') }} + + + + + {{ ytdlpVersion }} +

+
- - - -
- - + +
+

+ {{ t('Settings.External Downloader Settings.ffmpeg Executable Path') }} + + + + + {{ ffmpegVersion }} +

+
-
- - + + +
+ +
- + - - diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css index c575416576b6c..64105f4ce889e 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css @@ -146,6 +146,11 @@ min-inline-size: 220px; } +.downloadOptions :deep(.ft-input) { + background-color: var(--bg-color); + border: 1px solid var(--tertiary-text-color); +} + .downloadButtons { display: flex; flex-direction: column; diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue index 47dc3752cf06c..edd15ce480669 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue @@ -517,8 +517,9 @@ async function handleDownload(mode) { break case 'not-configured': case 'error': - showToast(t('Video.Video download failed, please configure the External Downloader Settings')) - router.push({ path: '/settings', query: { section: 'external-downloader' } }) + showToast(t('Video.Download failed - Click to open External Downloader settings'), 10000, () => { + router.push({ path: '/settings', query: { section: 'external-downloader' } }) + }) break } } diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index 535b7fc8c7b1f..96eb0f9a8c6fe 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -879,8 +879,8 @@ Video: End Time: End Time Video download has started: Video download has started Audio download has started: Audio download has started - Video download failed, please configure the External Downloader Settings: Video download - failed, please configure the External Downloader Settings + Download failed - Click to open External Downloader settings: Download failed - Click + to open External Downloader settings Open in YouTube: Open in YouTube Copy YouTube Link: Copy YouTube Link Open YouTube Embedded Player: Open YouTube Embedded Player @@ -1124,7 +1124,7 @@ Tooltips: and audio streams and for extracting audio-only downloads. If left empty, yt-dlp will use its own default lookup behavior. Output Directory: The directory yt-dlp should save downloaded videos to, passed via - yt-dlp's -o/--output option. If left empty, yt-dlp's own default output location is used. + yt-dlp's -o/--output option. Defaults to your Downloads folder. Video - Custom Arguments: Any custom command line arguments you want to be passed on to yt-dlp when downloading a video. Audio - Custom Arguments: Any custom command line arguments you want to be passed on to yt-dlp From c52eae9533d79930d747ce90038e3eaf6f2631be Mon Sep 17 00:00:00 2001 From: KerballOne Date: Mon, 10 Aug 2026 13:45:45 -0600 Subject: [PATCH 6/6] Removed ffmpeg handling entirely Fixed dropdown width. Replicated Snapshot behavior: Using a single Enable gate for the rest of the settings, a mode selector for ask every time for save folder or show output directory text input field. --- src/constants.js | 2 - src/main/download.js | 139 ++++++-- src/main/index.js | 44 +-- src/preload/interface.js | 19 +- .../components/ExternalDownloaderSettings.vue | 317 +++++++++--------- .../WatchVideoInfo/WatchVideoInfo.css | 10 +- .../WatchVideoInfo/WatchVideoInfo.vue | 11 +- src/renderer/store/modules/settings.js | 4 +- static/locales/en-US.yaml | 17 +- 9 files changed, 317 insertions(+), 246 deletions(-) diff --git a/src/constants.js b/src/constants.js index 24dfb4c1e4ffb..d9a6283b4cf5a 100644 --- a/src/constants.js +++ b/src/constants.js @@ -47,9 +47,7 @@ const IpcChannels = { WRITE_TO_DEFAULT_FOLDER: 'write-to-default-folder', CHOOSE_YTDLP_OUTPUT_DIRECTORY: 'choose-ytdlp-output-directory', - RESOLVE_YTDLP_OUTPUT_DIRECTORY: 'resolve-ytdlp-output-directory', CHOOSE_YTDLP_EXECUTABLE: 'choose-ytdlp-executable', - CHOOSE_FFMPEG_EXECUTABLE: 'choose-ffmpeg-executable', FIND_EXECUTABLE_ON_PATH: 'find-executable-on-path', GET_DOWNLOADER_EXECUTABLE_VERSIONS: 'get-downloader-executable-versions', diff --git a/src/main/download.js b/src/main/download.js index 1872b0cad4183..c614e5472218e 100644 --- a/src/main/download.js +++ b/src/main/download.js @@ -1,6 +1,7 @@ -import { app } from 'electron' +import { app, BrowserWindow, dialog } from 'electron' import { execFile, spawn } from 'node:child_process' import { access, constants } from 'node:fs/promises' +import { normalize } from 'node:path' import { promisify } from 'node:util' import { settings } from '../datastores/handlers/base' import { isFreeTubeUrl } from './utils' @@ -71,37 +72,31 @@ async function getVersion(executable, versionArgs) { /** * @param {string} ytdlpExecutable - * @param {string} ffmpegExecutable - * @returns {Promise<{ ytdlp: string | null, ffmpeg: string | null }>} + * @returns {Promise<{ ytdlp: string | null }>} */ -export async function getExecutableVersions(ytdlpExecutable, ffmpegExecutable) { - const [ytdlp, ffmpeg] = await Promise.all([ - getVersion(ytdlpExecutable, ['--version']), - getVersion(ffmpegExecutable, ['-version']) - ]) +export async function getExecutableVersions(ytdlpExecutable) { + const ytdlp = await getVersion(ytdlpExecutable, ['--version']) - const ffmpegVersion = ffmpeg?.match(/ffmpeg version (\S+)/)?.[1] ?? ffmpeg - - return { ytdlp, ffmpeg: ffmpegVersion } + return { ytdlp } } /** * Terminal emulators to try on Linux, in order, along with how each one * expects the command to run to be passed. - * @type {{ name: string, buildArgs: (executable: string, args: string[]) => string[] }[]} + * @type {{ name: string, buildArgs: (shellCommand: string) => string[] }[]} */ const LINUX_TERMINALS = [ - { name: 'x-terminal-emulator', buildArgs: (executable, args) => ['-e', executable, ...args] }, - { name: 'gnome-terminal', buildArgs: (executable, args) => ['--', executable, ...args] }, - { name: 'konsole', buildArgs: (executable, args) => ['-e', executable, ...args] }, - { name: 'xfce4-terminal', buildArgs: (executable, args) => ['-x', executable, ...args] }, - { name: 'kitty', buildArgs: (executable, args) => [executable, ...args] }, - { name: 'alacritty', buildArgs: (executable, args) => ['-e', executable, ...args] }, - { name: 'xterm', buildArgs: (executable, args) => ['-e', executable, ...args] }, + { name: 'x-terminal-emulator', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, + { name: 'gnome-terminal', buildArgs: (shellCommand) => ['--', 'sh', '-c', shellCommand] }, + { name: 'konsole', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, + { name: 'xfce4-terminal', buildArgs: (shellCommand) => ['-x', 'sh', '-c', shellCommand] }, + { name: 'kitty', buildArgs: (shellCommand) => ['sh', '-c', shellCommand] }, + { name: 'alacritty', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, + { name: 'xterm', buildArgs: (shellCommand) => ['-e', 'sh', '-c', shellCommand] }, ] /** - * @returns {Promise<{ name: string, buildArgs: (executable: string, args: string[]) => string[] } | null>} + * @returns {Promise<{ name: string, buildArgs: (shellCommand: string) => string[] } | null>} */ async function findLinuxTerminal() { for (const terminal of LINUX_TERMINALS) { @@ -114,7 +109,43 @@ async function findLinuxTerminal() { } /** - * @typedef {'ok' | 'invalid' | 'not-configured' | 'error'} DownloadVideoResult + * @param {string} path + * @returns {Promise} + */ +async function hasWriteAccess(path) { + try { + await access(path, constants.W_OK) + return true + } catch { + return false + } +} + +/** + * @param {import('electron').WebContents} webContents + * @param {string | undefined} [defaultPath] + * @returns {Promise} + */ +async function promptForOutputDirectory(webContents, defaultPath) { + const dialogOptions = { + defaultPath: typeof defaultPath === 'string' && defaultPath.length > 0 ? defaultPath : app.getPath('downloads'), + properties: ['openDirectory'] + } + + const window = BrowserWindow.fromWebContents(webContents) + const result = window + ? await dialog.showOpenDialog(window, dialogOptions) + : await dialog.showOpenDialog(dialogOptions) + + if (result.canceled) { + return null + } + + return result.filePaths[0] +} + +/** + * @typedef {'ok' | 'invalid' | 'not-configured' | 'disabled' | 'cancelled' | 'error'} DownloadVideoResult */ /** @@ -137,6 +168,13 @@ export async function handleDownloadVideo(event, payload) { return 'invalid' } + /** @type {boolean} */ + const downloadEnabled = (await settings._findOne('ytdlpDownloadEnabled'))?.value || false + + if (!downloadEnabled) { + return 'disabled' + } + const hasValidStartTime = typeof startTime === 'number' && startTime >= 0 const hasValidEndTime = typeof endTime === 'number' && endTime > 0 @@ -148,10 +186,23 @@ export async function handleDownloadVideo(event, payload) { } /** @type {string} */ - const outputDirectory = (await settings._findOne('ytdlpOutputDirectory'))?.value || app.getPath('downloads') + const downloadMode = (await settings._findOne('ytdlpDownloadMode'))?.value || 'prompt_folder' /** @type {string} */ - const ffmpegExecutable = (await settings._findOne('ffmpegExecutable'))?.value || '' + const storedOutputDirectory = (await settings._findOne('ytdlpOutputDirectory'))?.value || '' + + const canUseStoredDirectory = downloadMode === 'default_folder' && storedOutputDirectory.length > 0 && + await hasWriteAccess(normalize(storedOutputDirectory)) + + // Either "always ask" mode, or the stored folder is unset/no longer writable + // (e.g. a Flatpak-portal-granted folder that got revoked) - prompt for one. + const outputDirectory = canUseStoredDirectory + ? storedOutputDirectory + : await promptForOutputDirectory(event.sender, storedOutputDirectory) + + if (!outputDirectory) { + return 'cancelled' + } const customArgsSettingId = mode === 'audio' ? 'ytdlpAudioCustomArgs' : 'ytdlpVideoCustomArgs' @@ -162,10 +213,6 @@ export async function handleDownloadVideo(event, payload) { const args = ['-o', `${outputDirectory}/%(title)s.%(ext)s`] - if (ffmpegExecutable.length > 0) { - args.push('--ffmpeg-location', ffmpegExecutable) - } - if (hasValidStartTime || hasValidEndTime) { const start = hasValidStartTime ? startTime : 0 const end = hasValidEndTime ? endTime : 'inf' @@ -182,17 +229,25 @@ export async function handleDownloadVideo(event, payload) { args.push(videoUrl) + const fullCommand = [executable, ...args] + if (process.platform === 'win32') { + // echo doesn't parse quotes, so the display line is only quoted where a part has a space + const displayCommand = fullCommand.map(part => part.includes(' ') ? `"${part}"` : part).join(' ') // cmd /k only strips quotes if they enclose the whole string, so wrap it twice - const innerCommand = [executable, ...args].map(part => `"${part.replaceAll('"', '""')}"`).join(' ') + const runCommand = fullCommand.map(part => `"${part.replaceAll('"', '""')}"`).join(' ') + const innerCommand = `echo ${displayCommand} && ${runCommand}` return spawnAndAwait('cmd.exe', ['/c', 'start', '""', '/wait', 'cmd.exe', '/k', `"${innerCommand}"`], { windowsVerbatimArguments: true }) } + const shellCommand = `echo ${quoteForShellDisplay(fullCommand)} && exec ${quoteForShell(fullCommand)}` + if (process.platform === 'darwin') { - return spawnAndAwait('open', ['-a', 'Terminal', '-n', '--args', executable, ...args]) + const appleScript = `tell application "Terminal" to do script ${quoteForAppleScript(shellCommand)}` + return spawnAndAwait('osascript', ['-e', appleScript]) } const terminal = await findLinuxTerminal() @@ -201,7 +256,31 @@ export async function handleDownloadVideo(event, payload) { return 'error' } - return spawnAndAwait(terminal.name, terminal.buildArgs(executable, args)) + return spawnAndAwait(terminal.name, terminal.buildArgs(shellCommand)) +} + +/** + * @param {string[]} parts + * @returns {string} + */ +function quoteForShell(parts) { + return parts.map(part => `'${part.replaceAll("'", "'\\''")}'`).join(' ') +} + +/** + * @param {string[]} parts + * @returns {string} + */ +function quoteForShellDisplay(parts) { + return parts.map(part => part.includes(' ') ? `'${part}'` : part).join(' ') +} + +/** + * @param {string} command + * @returns {string} + */ +function quoteForAppleScript(command) { + return `"${command.replaceAll('\\', '\\\\').replaceAll('"', '\\"')}"` } /** diff --git a/src/main/index.js b/src/main/index.js index eb759915f7af5..2551932bda187 100644 --- a/src/main/index.js +++ b/src/main/index.js @@ -1456,29 +1456,13 @@ function runApp() { }) } - ipcMain.handle(IpcChannels.RESOLVE_YTDLP_OUTPUT_DIRECTORY, async (event) => { + ipcMain.handle(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY, async (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { return null } const currentPath = (await baseHandlers.settings._findOne('ytdlpOutputDirectory'))?.value - if (typeof currentPath === 'string' && currentPath.length > 0) { - return currentPath - } - - const defaultPath = app.getPath('downloads') - await persistAndSyncSetting('ytdlpOutputDirectory', defaultPath) - return defaultPath - }) - - ipcMain.on(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY, async (event) => { - if (!isFreeTubeUrl(event.senderFrame.url)) { - return - } - - const currentPath = (await baseHandlers.settings._findOne('ytdlpOutputDirectory'))?.value - const dialogOptions = { defaultPath: typeof currentPath === 'string' && currentPath.length > 0 ? currentPath : app.getPath('downloads'), properties: ['openDirectory'] @@ -1490,19 +1474,21 @@ function runApp() { : await dialog.showOpenDialog(dialogOptions) if (result.canceled) { - return + return null } await persistAndSyncSetting('ytdlpOutputDirectory', result.filePaths[0]) + return result.filePaths[0] }) /** - * @param {import('electron').IpcMainEvent} event + * @param {import('electron').IpcMainInvokeEvent} event * @param {string} settingId + * @returns {Promise} */ async function chooseExecutable(event, settingId) { if (!isFreeTubeUrl(event.senderFrame.url)) { - return + return null } const currentPath = (await baseHandlers.settings._findOne(settingId))?.value @@ -1524,18 +1510,15 @@ function runApp() { : await dialog.showOpenDialog(dialogOptions) if (result.canceled) { - return + return null } await persistAndSyncSetting(settingId, result.filePaths[0]) + return result.filePaths[0] } - ipcMain.on(IpcChannels.CHOOSE_YTDLP_EXECUTABLE, async (event) => { - await chooseExecutable(event, 'ytdlpExecutable') - }) - - ipcMain.on(IpcChannels.CHOOSE_FFMPEG_EXECUTABLE, async (event) => { - await chooseExecutable(event, 'ffmpegExecutable') + ipcMain.handle(IpcChannels.CHOOSE_YTDLP_EXECUTABLE, async (event) => { + return chooseExecutable(event, 'ytdlpExecutable') }) ipcMain.on(IpcChannels.CHOOSE_DEFAULT_FOLDER, async (event) => { @@ -1683,7 +1666,7 @@ function runApp() { if ( !isFreeTubeUrl(event.senderFrame.url) || typeof name !== 'string' || !/^[\w-]+$/.test(name) || - typeof settingId !== 'string' || !['ytdlpExecutable', 'ffmpegExecutable'].includes(settingId) + settingId !== 'ytdlpExecutable' ) { return null } @@ -1701,13 +1684,12 @@ function runApp() { ipcMain.handle(IpcChannels.GET_DOWNLOADER_EXECUTABLE_VERSIONS, async (event) => { if (!isFreeTubeUrl(event.senderFrame.url)) { - return { ytdlp: null, ffmpeg: null } + return { ytdlp: null } } const ytdlpExecutable = (await baseHandlers.settings._findOne('ytdlpExecutable'))?.value || '' - const ffmpegExecutable = (await baseHandlers.settings._findOne('ffmpegExecutable'))?.value || '' - return getExecutableVersions(ytdlpExecutable, ffmpegExecutable) + return getExecutableVersions(ytdlpExecutable) }) ipcMain.handle(IpcChannels.GET_REPLACE_HTTP_CACHE, (event) => { diff --git a/src/preload/interface.js b/src/preload/interface.js index 657b34ddc9f4f..0149f05141083 100644 --- a/src/preload/interface.js +++ b/src/preload/interface.js @@ -131,28 +131,23 @@ export default { ipcRenderer.send(IpcChannels.CHOOSE_DEFAULT_FOLDER) }, + /** + * @returns {Promise} + */ chooseYtdlpOutputDirectory: () => { - ipcRenderer.send(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY) + return ipcRenderer.invoke(IpcChannels.CHOOSE_YTDLP_OUTPUT_DIRECTORY) }, /** * @returns {Promise} */ - resolveYtdlpOutputDirectory: () => { - return ipcRenderer.invoke(IpcChannels.RESOLVE_YTDLP_OUTPUT_DIRECTORY) - }, - chooseYtdlpExecutable: () => { - ipcRenderer.send(IpcChannels.CHOOSE_YTDLP_EXECUTABLE) - }, - - chooseFfmpegExecutable: () => { - ipcRenderer.send(IpcChannels.CHOOSE_FFMPEG_EXECUTABLE) + return ipcRenderer.invoke(IpcChannels.CHOOSE_YTDLP_EXECUTABLE) }, /** * @param {string} name - * @param {'ytdlpExecutable' | 'ffmpegExecutable'} settingId + * @param {'ytdlpExecutable'} settingId * @returns {Promise} */ resolveExecutablePath: (name, settingId) => { @@ -160,7 +155,7 @@ export default { }, /** - * @returns {Promise<{ ytdlp: string | null, ffmpeg: string | null }>} + * @returns {Promise<{ ytdlp: string | null }>} */ getDownloaderExecutableVersions: () => { return ipcRenderer.invoke(IpcChannels.GET_DOWNLOADER_EXECUTABLE_VERSIONS) diff --git a/src/renderer/components/ExternalDownloaderSettings.vue b/src/renderer/components/ExternalDownloaderSettings.vue index 14d7a38aa6394..1734bc03aab82 100644 --- a/src/renderer/components/ExternalDownloaderSettings.vue +++ b/src/renderer/components/ExternalDownloaderSettings.vue @@ -2,129 +2,113 @@ - + + - -
-

- {{ t('Settings.External Downloader Settings.yt-dlp Executable Path') }} - + +

+

+ {{ t('Settings.External Downloader Settings.yt-dlp Executable Path') }} + + + + + {{ ytdlpVersion }} +

+ - - - - {{ ytdlpVersion }} -

- + -
- - - -
-

- {{ t('Settings.External Downloader Settings.ffmpeg Executable Path') }} - + +

+ - - - - {{ ffmpegVersion }} -

- -
- - - -
- -
- -
- -
- -
-
- -
-
+
+
+ + +
+
+ +
+ +
+
+ +
+
+ @@ -137,6 +121,8 @@ import FtSettingsSection from './FtSettingsSection/FtSettingsSection.vue' import FtInput from './FtInput/FtInput.vue' import FtButton from './FtButton/FtButton.vue' import FtFlexBox from './ft-flex-box/ft-flex-box.vue' +import FtSelect from './FtSelect/FtSelect.vue' +import FtToggleSwitch from './FtToggleSwitch/FtToggleSwitch.vue' import FtTooltip from './FtTooltip/FtTooltip.vue' import store from '../store/index' @@ -144,11 +130,11 @@ import { debounce, openExternalLink } from '../helpers/utils' const { t } = useI18n() -/** @type {import('vue').ComputedRef} */ -const ytdlpExecutable = computed(() => store.getters.getYtdlpExecutable) +/** @type {import('vue').ComputedRef} */ +const downloadEnabled = computed(() => store.getters.getYtdlpDownloadEnabled) /** @type {import('vue').ComputedRef} */ -const ffmpegExecutable = computed(() => store.getters.getFfmpegExecutable) +const ytdlpExecutable = computed(() => store.getters.getYtdlpExecutable) /** @type {import('vue').ComputedRef} */ const ytdlpOutputDirectory = computed(() => store.getters.getYtdlpOutputDirectory) @@ -160,38 +146,52 @@ const ytdlpVideoCustomArgs = computed(() => store.getters.getYtdlpVideoCustomArg const ytdlpAudioCustomArgs = computed(() => store.getters.getYtdlpAudioCustomArgs) /** - * @param {string} value + * @param {boolean} value */ -function updateYtdlpExecutable(value) { - store.dispatch('updateYtdlpExecutable', value) - debouncedRefreshVersions() +function updateDownloadEnabled(value) { + store.dispatch('updateYtdlpDownloadEnabled', value) } -async function chooseYtdlpExecutable() { - if (process.env.IS_ELECTRON) { - await window.ftElectron.chooseYtdlpExecutable() - await refreshVersions() - } +const downloadModeNames = computed(() => [ + t('Settings.External Downloader Settings.Output Directory Modes.Ask Path'), + t('Settings.External Downloader Settings.Output Directory Modes.Save To Folder'), +]) +const downloadModeValues = computed(() => ['prompt_folder', 'default_folder']) + +/** @type {import('vue').ComputedRef<'prompt_folder' | 'default_folder'>} */ +const downloadMode = computed(() => store.getters.getYtdlpDownloadMode) + +/** + * @param {'prompt_folder' | 'default_folder'} value + */ +function updateDownloadMode(value) { + store.dispatch('updateYtdlpDownloadMode', value) } /** * @param {string} value */ -function updateFfmpegExecutable(value) { - store.dispatch('updateFfmpegExecutable', value) - debouncedRefreshVersions() +async function updateYtdlpExecutable(value) { + store.dispatch('updateYtdlpExecutable', value) + await debouncedRefreshVersion() } -async function chooseFfmpegExecutable() { +async function chooseYtdlpExecutable() { if (process.env.IS_ELECTRON) { - await window.ftElectron.chooseFfmpegExecutable() - await refreshVersions() + const chosenPath = await window.ftElectron.chooseYtdlpExecutable() + if (chosenPath) { + store.dispatch('updateYtdlpExecutable', chosenPath) + } + await refreshVersion() } } -function chooseYtdlpOutputDirectory() { +async function chooseYtdlpOutputDirectory() { if (process.env.IS_ELECTRON) { - window.ftElectron.chooseYtdlpOutputDirectory() + const chosenPath = await window.ftElectron.chooseYtdlpOutputDirectory() + if (chosenPath) { + store.dispatch('updateYtdlpOutputDirectory', chosenPath) + } } } @@ -224,24 +224,18 @@ function openYtdlpReleases() { openExternalLink('https://github.com/yt-dlp/yt-dlp/releases') } -function openFfmpegReleases() { - openExternalLink('https://github.com/yt-dlp/FFmpeg-Builds/releases') -} - const ytdlpVersion = ref('') -const ffmpegVersion = ref('') -async function refreshVersions() { +async function refreshVersion() { if (!process.env.IS_ELECTRON) { return } - const { ytdlp, ffmpeg } = await window.ftElectron.getDownloaderExecutableVersions() + const { ytdlp } = await window.ftElectron.getDownloaderExecutableVersions() ytdlpVersion.value = ytdlp || '' - ffmpegVersion.value = ffmpeg || '' } -const debouncedRefreshVersions = debounce(refreshVersions, 500) +const debouncedRefreshVersion = debounce(refreshVersion, 500) onMounted(async () => { if (!process.env.IS_ELECTRON) { @@ -253,17 +247,7 @@ onMounted(async () => { store.dispatch('updateYtdlpExecutable', resolvedYtdlp) } - const resolvedFfmpeg = await window.ftElectron.resolveExecutablePath('ffmpeg', 'ffmpegExecutable') - if (resolvedFfmpeg && resolvedFfmpeg !== ffmpegExecutable.value) { - store.dispatch('updateFfmpegExecutable', resolvedFfmpeg) - } - - const resolvedOutputDirectory = await window.ftElectron.resolveYtdlpOutputDirectory() - if (resolvedOutputDirectory && resolvedOutputDirectory !== ytdlpOutputDirectory.value) { - store.dispatch('updateYtdlpOutputDirectory', resolvedOutputDirectory) - } - - await refreshVersions() + await refreshVersion() }) @@ -273,8 +257,8 @@ onMounted(async () => { z-index: 1; } -.readmeRow { - justify-content: flex-start; +.topRow { + justify-content: space-between; } .customArgsRow { @@ -304,6 +288,27 @@ onMounted(async () => { margin-block-end: 10px; } +.modeSelectWrapper { + padding-block-start: 24px; +} + +.modeSelectWrapper :deep(.select) { + margin-block-start: 0; +} + +.outputDirectoryField { + display: flex; + flex: 1; + align-items: flex-end; + gap: 10px; + padding-block-start: 5px; +} + +.outputDirectoryField :deep(.ft-input-component) { + flex: 1; + min-inline-size: 0; +} + .downloadLink { color: var(--primary-text-color); margin-inline-start: 8px; diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css index 64105f4ce889e..03018e9844cb5 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.css @@ -141,9 +141,11 @@ } .downloadOptions { - padding-inline: 12px; - padding-block-start: 12px; - min-inline-size: 220px; + padding: 12px; + padding-block-end: 0; + max-inline-size: min-content; + min-inline-size: 150px; + margin-inline: auto; } .downloadOptions :deep(.ft-input) { @@ -154,9 +156,11 @@ .downloadButtons { display: flex; flex-direction: column; + align-items: center; padding: 12px; max-inline-size: min-content; min-inline-size: 150px; + margin-inline: auto; } .downloadButtons .action { diff --git a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue index edd15ce480669..f33d5d171e119 100644 --- a/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue +++ b/src/renderer/components/WatchVideoInfo/WatchVideoInfo.vue @@ -112,7 +112,7 @@ @click="handleExternalPlayer" /> store.getters.getHistoryCacheById[prop /** @type {import('vue').ComputedRef} */ const externalPlayer = computed(() => store.getters.getExternalPlayer) +/** @type {import('vue').ComputedRef} */ +const downloadEnabled = computed(() => store.getters.getYtdlpDownloadEnabled) + /** @type {import('vue').ComputedRef} */ const defaultPlayback = computed(() => store.getters.getDefaultPlayback) @@ -515,6 +518,10 @@ async function handleDownload(mode) { ? t('Video.Audio download has started') : t('Video.Video download has started')) break + case 'cancelled': + // user closed the folder picker, nothing to report + break + case 'disabled': case 'not-configured': case 'error': showToast(t('Video.Download failed - Click to open External Downloader settings'), 10000, () => { diff --git a/src/renderer/store/modules/settings.js b/src/renderer/store/modules/settings.js index 8fcb8e242c2b5..540f5b47497ea 100644 --- a/src/renderer/store/modules/settings.js +++ b/src/renderer/store/modules/settings.js @@ -189,8 +189,9 @@ const state = { externalPlayerIgnoreDefaultArgs: false, externalPlayerCustomArgs: '[]', showAddedExternalPlayerCustomArgs: true, + ytdlpDownloadEnabled: false, + ytdlpDownloadMode: 'prompt_folder', ytdlpExecutable: '', - ffmpegExecutable: '', ytdlpOutputDirectory: '', ytdlpVideoCustomArgs: '', ytdlpAudioCustomArgs: '', @@ -447,7 +448,6 @@ export const NON_TRANSFERABLE_SETTINGS = new Set([ 'showAddedExternalPlayerCustomArgs', // ExternalDownloaderSettings 'ytdlpExecutable', - 'ffmpegExecutable', 'ytdlpOutputDirectory', 'ytdlpVideoCustomArgs', 'ytdlpAudioCustomArgs', diff --git a/static/locales/en-US.yaml b/static/locales/en-US.yaml index 96eb0f9a8c6fe..c9c3f68f7ba6d 100644 --- a/static/locales/en-US.yaml +++ b/static/locales/en-US.yaml @@ -509,12 +509,15 @@ Settings: Name: None External Downloader Settings: External Downloader Settings: External Downloader + Enable Downloads: Enable Downloads yt-dlp Readme: yt-dlp Readme yt-dlp Executable Path: yt-dlp Executable Path Download yt-dlp: Download yt-dlp - ffmpeg Executable Path: ffmpeg Executable Path - Download FFmpeg: Download FFmpeg Choose Executable: Choose Executable + Output Directory Mode: Output Directory Mode + Output Directory Modes: + Ask Path: Ask Path + Save To Folder: Save To Folder Output Directory: Output Directory Choose Output Directory: Choose Output Directory Video - Custom Arguments: Video - Custom Arguments @@ -874,7 +877,7 @@ Video: Download: Video: Video Audio: Audio - Include Timestamp: Include Timestamp + Timespan: Timespan Start Time: Start Time End Time: End Time Video download has started: Video download has started @@ -1119,12 +1122,10 @@ Tooltips: yt-dlp Executable Path: By default, FreeTube will assume that yt-dlp can be found via the PATH environment variable. If needed, a custom path to the yt-dlp executable can be set here. - ffmpeg Executable Path: The path to the ffmpeg executable, passed to yt-dlp via its - --ffmpeg-location option. ffmpeg is required by yt-dlp for merging separate video - and audio streams and for extracting audio-only downloads. If left empty, yt-dlp - will use its own default lookup behavior. + Output Directory Mode: Ask Path will prompt you to choose a folder every time you download. + Save To Folder lets you set one folder to always download to. Output Directory: The directory yt-dlp should save downloaded videos to, passed via - yt-dlp's -o/--output option. Defaults to your Downloads folder. + yt-dlp's -o/--output option. Video - Custom Arguments: Any custom command line arguments you want to be passed on to yt-dlp when downloading a video. Audio - Custom Arguments: Any custom command line arguments you want to be passed on to yt-dlp