diff --git a/package.json b/package.json index d09f36ff68..1d3c381d93 100644 --- a/package.json +++ b/package.json @@ -1,9 +1,9 @@ { - "name": "\u0079\u006f\u0075\u0074\u0075\u0062\u0065\u002d\u006d\u0075\u0073\u0069\u0063", - "desktopName": "com.github.th_ch.\u0079\u006f\u0075\u0074\u0075\u0062\u0065\u005f\u006d\u0075\u0073\u0069\u0063", - "productName": "\u0059\u006f\u0075\u0054\u0075\u0062\u0065\u0020\u004d\u0075\u0073\u0069\u0063", + "name": "youtube-music", + "desktopName": "com.github.th_ch.youtube_music", + "productName": "YouTube Music", "version": "3.11.0", - "description": "\u0059\u006f\u0075\u0054\u0075\u0062\u0065\u0020\u004d\u0075\u0073\u0069\u0063 Desktop App - including custom plugins", + "description": "YouTube Music Desktop App - including custom plugins", "main": "./dist/main/index.js", "type": "module", "license": "MIT", @@ -133,6 +133,7 @@ "tinyld": "1.3.4", "virtua": "0.48.5", "vudio": "2.1.1", + "which": "^6.0.0", "x11": "2.3.0", "youtubei.js": "^16.0.1", "zod": "4.2.1" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fab0c4f6d..769bfe410d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -246,6 +246,9 @@ importers: vudio: specifier: 2.1.1 version: 2.1.1(patch_hash=0e06c2ed11c02bdc490c209fa80070e98517c2735c641f5738b6e15d7dc1959d) + which: + specifier: ^6.0.0 + version: 6.0.0 x11: specifier: 2.3.0 version: 2.3.0 @@ -286,6 +289,9 @@ importers: '@types/trusted-types': specifier: 2.0.7 version: 2.0.7 + '@types/which': + specifier: ^3.0.4 + version: 3.0.4 bufferutil: specifier: 4.1.0 version: 4.1.0 @@ -1321,6 +1327,8 @@ packages: '@types/whatwg-mimetype@3.0.2': resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + '@types/which@3.0.4': + resolution: {integrity: sha512-liyfuo/106JdlgSchJzXEQCVArk0CvevqPote8F8HgWgJ3dRCcTHgJIsLDuee0kxk/mhbInzIZk3QWSZJ8R+2w==} '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -4662,6 +4670,11 @@ packages: engines: {node: ^18.17.0 || >=20.5.0} hasBin: true + which@6.0.0: + resolution: {integrity: sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==} + engines: {node: ^20.17.0 || >=22.9.0} + hasBin: true + widest-line@4.0.1: resolution: {integrity: sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==} engines: {node: '>=12'} @@ -5813,10 +5826,11 @@ snapshots: optional: true '@types/whatwg-mimetype@3.0.2': {} + '@types/which@3.0.4': {} - '@types/ws@8.18.1': + '@types/ws@8.18.1': dependencies: - '@types/node': 25.1.0 + '@types/node': 25.1. '@types/yauzl@2.10.3': dependencies: @@ -9513,6 +9527,10 @@ snapshots: dependencies: isexe: 3.1.1 + which@6.0.0: + dependencies: + isexe: 3.1.1 + widest-line@4.0.1: dependencies: string-width: 5.1.2 diff --git a/src/plugins/downloader/index.ts b/src/plugins/downloader/index.ts old mode 100644 new mode 100755 index 914f7db76b..db7591d048 --- a/src/plugins/downloader/index.ts +++ b/src/plugins/downloader/index.ts @@ -11,6 +11,12 @@ import { t } from '@/i18n'; export type DownloaderPluginConfig = { enabled: boolean; downloadFolder?: string; + // Engine to use for downloading: either the built-in youtube.js or external yt-dlp + engine?: 'youtube.js' | 'yt-dlp'; + // Path to the yt-dlp executable (when using yt-dlp). Defaults to common system path. + ytdlpPath?: string; + // Optional path to ffmpeg/ffprobe executable or folder for yt-dlp + ytdlpFfmpegPath?: string; downloadOnFinish?: { enabled: boolean; seconds: number; @@ -26,6 +32,9 @@ export type DownloaderPluginConfig = { export const defaultConfig: DownloaderPluginConfig = { enabled: false, + engine: 'youtube.js', + ytdlpPath: undefined, + ytdlpFfmpegPath: undefined, downloadFolder: undefined, downloadOnFinish: { enabled: false, diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts old mode 100644 new mode 100755 index 33d58ce444..8370d4c7aa --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -1,8 +1,10 @@ -import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; import { app, type BrowserWindow, dialog, ipcMain } from 'electron'; +import { spawn, spawnSync } from 'node:child_process'; +import { which } from 'which'; import { Innertube, UniversalCache, @@ -441,6 +443,171 @@ async function downloadSongUnsafe( return; } + // If configured to use yt-dlp, delegate downloading to the external binary + if ((config.engine ?? 'youtube.js') === 'yt-dlp') { + const findYtdlpExecutable = async (preferred?: string) => { + const candidates: (string | undefined)[] = []; + if (preferred) candidates.push(preferred); + + // Try to find yt-dlp using the which command + try { + const foundPath = await which('yt-dlp', { nothrow: true }); + if (foundPath) return foundPath; + } catch (_) { + // ignore if which fails + } + + for (const c of candidates) { + if (!c) continue; + try { + // Try running `--version` to check it's executable + const res = spawnSync(c, ['--version'], { encoding: 'utf8', stdio: 'ignore' }); + if (res && res.status === 0) return c; + } catch (_) { + // ignore and try next + } + } + return null; + }; + + const findFfmpegExecutable = async (preferred?: string) => { + const candidates: (string | undefined)[] = []; + if (preferred) candidates.push(preferred); + + // Try to find ffmpeg using the which command + try { + const foundPath = await which('ffmpeg', { nothrow: true }); + if (foundPath) return foundPath; + } catch (_) { + // ignore if which fails + } + + return null; + }; + + const ytdlpExecutable = await findYtdlpExecutable(config.ytdlpPath ?? undefined); + const ffmpegPath = await findFfmpegExecutable(config.ytdlpFfmpegPath ?? undefined); + + // Check if both yt-dlp and ffmpeg are available + if (!ytdlpExecutable) { + dialog.showMessageBox(win, { + type: 'error', + buttons: ['OK'], + title: t('plugins.downloader.backend.dialog.ytdlp-not-found.title') || 'yt-dlp introuvable', + message: + t('plugins.downloader.backend.dialog.ytdlp-not-found.message') || + "yt-dlp n'a pas été trouvé. Veuillez installer yt-dlp ou indiquer le chemin complet dans le menu du plugin.", + }); + return; + } + + if (!ffmpegPath) { + dialog.showMessageBox(win, { + type: 'error', + buttons: ['OK'], + title: t('plugins.downloader.backend.dialog.ffmpeg-not-found.title') || 'ffmpeg introuvable', + message: + t('plugins.downloader.backend.dialog.ffmpeg-not-found.message') || + "ffmpeg n'a pas été trouvé. Veuillez installer ffmpeg ou indiquer le chemin complet dans le menu du plugin.", + }); + return; + } + + const baseName = filename.replace(new RegExp(`\\.${targetFileExtension}$`), ''); + const outputTemplate = + targetFileExtension === 'mp3' + ? join(dir, `${baseName}.mp3`) + : join(dir, `${baseName}.%(ext)s`); + + const urlToDownload = isId ? `https://www.youtube.com/watch?v=${id}` : idOrUrl; + + const args: string[] = ['--no-playlist', '--add-metadata', '--embed-thumbnail', '--output', outputTemplate]; + + if (targetFileExtension === 'mp3') { + args.unshift('--extract-audio', '--audio-format', 'mp3', '--audio-quality', '0'); + } else { + args.unshift('-f', 'best'); + } + + // Pass ffmpeg location to yt-dlp + args.push('--ffmpeg-location', ffmpegPath); + + sendFeedback(t('plugins.downloader.backend.feedback.downloading'), 2); + + await new Promise((resolve, reject) => { + try { + const proc = spawn(ytdlpExecutable, [...args, urlToDownload], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + + const outChunks: string[] = []; + const errChunks: string[] = []; + + proc.stdout.on('data', (d) => { + const s = String(d); + outChunks.push(s); + sendFeedback(s); + }); + proc.stderr.on('data', (d) => { + const s = String(d); + errChunks.push(s); + sendFeedback(s); + }); + + proc.on('close', (code) => { + const out = outChunks.join(''); + const err = errChunks.join(''); + if (code === 0) { + sendFeedback(null, -1); + resolve(); + return; + } + + const tail = (str: string, max = 2000) => + str.length > max ? '...\n' + str.slice(-max) : str; + + dialog.showMessageBox(win, { + type: 'error', + buttons: ['OK'], + defaultId: 0, + title: + t('plugins.downloader.backend.dialog.ytdlp-error.title') || + `yt-dlp erreur`, + message: + t('plugins.downloader.backend.dialog.ytdlp-error.message') || + `yt-dlp a échoué avec le code ${code}`, + detail: + t('plugins.downloader.backend.dialog.ytdlp-error.detail') || + `Derniers messages d'erreur:\n${tail(err)}\n\nSortie:\n${tail(out)}`, + }); + + reject(new Error(`yt-dlp exited with code ${code}: ${tail(err, 500)}`)); + }); + + proc.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EACCES') { + dialog.showMessageBox(win, { + type: 'error', + buttons: ['OK'], + title: + t('plugins.downloader.backend.dialog.ytdlp-permission.title') || + 'Permission refusée', + message: + t('plugins.downloader.backend.dialog.ytdlp-permission.message') || + `Impossible d'exécuter ${ytdlpExecutable} (EACCES). Rendre le fichier exécutable : chmod +x ${ytdlpExecutable} ou choisissez un autre chemin dans le menu du plugin.`, + }); + } + reject(err); + }); + } catch (err) { + reject(err); + } + }); + + // ytdlp already wrote the file to disk using the output template. We're done. + return; + } + const stream = await info.download(downloadOptions); console.info( diff --git a/src/plugins/downloader/main/utils.ts b/src/plugins/downloader/main/utils.ts old mode 100644 new mode 100755 diff --git a/src/plugins/downloader/menu.ts b/src/plugins/downloader/menu.ts old mode 100644 new mode 100755 index 8838e73ecf..7cebaa9765 --- a/src/plugins/downloader/menu.ts +++ b/src/plugins/downloader/menu.ts @@ -1,6 +1,8 @@ import { dialog } from 'electron'; import prompt from 'custom-electron-prompt'; import { deepmerge } from 'deepmerge-ts'; +import { spawn, spawnSync } from 'node:child_process'; +import { which } from 'which'; import { downloadPlaylist } from './main'; import { getFolder } from './main/utils'; @@ -20,6 +22,65 @@ export const onMenu = async ({ setConfig, }: MenuContext): Promise => { const config = await getConfig(); + + const findYtdlpExecutable = async (preferred?: string) => { + const candidates: (string | undefined)[] = []; + if (preferred) candidates.push(preferred); + + // Try to find yt-dlp using the which command + try { + const foundPath = await which('yt-dlp', { nothrow: true }); + if (foundPath) return foundPath; + } catch (_) { + // ignore if which fails + } + + for (const c of candidates) { + if (!c) continue; + try { + // Try running `--version` to check it's executable + const res = spawnSync(c, ['--version'], { encoding: 'utf8', stdio: 'ignore' }); + if (res && res.status === 0) return c; + } catch (_) { + // ignore and try next + } + } + return null; + }; + + const findFfmpegExecutable = async (preferred?: string) => { + const candidates: (string | undefined)[] = []; + if (preferred) candidates.push(preferred); + + // Try to find ffmpeg using the which command + try { + const foundPath = await which('ffmpeg', { nothrow: true }); + if (foundPath) return foundPath; + } catch (_) { + // ignore if which fails + } + + return null; + }; + + const _engineKey = 'plugins.downloader.menu.engine.label'; + const engineTranslated = t(_engineKey); + const engineLabel = + typeof engineTranslated === 'string' && !engineTranslated.includes(_engineKey) + ? engineTranslated + : 'Download Method'; + const _ytdlpKey = 'plugins.downloader.menu.engine.ytdlp-path'; + const ytdlpTranslated = t(_ytdlpKey); + const ytdlpLabel = + typeof ytdlpTranslated === 'string' && !ytdlpTranslated.includes(_ytdlpKey) + ? ytdlpTranslated + : 'Path of yt-dlp'; + const _ffmpegKey = 'plugins.downloader.menu.engine.ffmpeg-path'; + const ffmpegTranslated = t(_ffmpegKey); + const ffmpegLabel = + typeof ffmpegTranslated === 'string' && !ffmpegTranslated.includes(_ffmpegKey) + ? ffmpegTranslated + : 'Path of ffmpeg'; return [ { @@ -206,6 +267,81 @@ export const onMenu = async ({ }, })), }, + { + label: engineLabel, + type: 'submenu', + submenu: [ + { + label: 'youtube.js', + type: 'radio', + checked: (config.engine ?? defaultConfig.engine) !== 'yt-dlp', + click() { + setConfig({ engine: 'youtube.js' }); + }, + }, + { + label: 'yt-dlp', + type: 'radio', + checked: (config.engine ?? defaultConfig.engine) === 'yt-dlp', + click: async () => { + // Check if yt-dlp and ffmpeg are available before allowing switch + const ytdlpPath = await findYtdlpExecutable(config.ytdlpPath ?? undefined); + const ffmpegPath = await findFfmpegExecutable(config.ytdlpFfmpegPath ?? undefined); + + if (!ytdlpPath) { + dialog.showMessageBoxSync({ + type: 'error', + buttons: ['OK'], + title: 'yt-dlp not found', + message: + "yt-dlp not found. Please install yt-dlp or provide the path in the settings.", + }); + return; + } + + if (!ffmpegPath) { + dialog.showMessageBoxSync({ + type: 'error', + buttons: ['OK'], + title: 'ffmpeg not found', + message: + "ffmpeg not found. Please install ffmpeg or provide the path in the settings.", + }); + return; + } + + setConfig({ engine: 'yt-dlp' }); + }, + }, + { + type: 'separator', + }, + { + label: ytdlpLabel, + click() { + const result = dialog.showOpenDialogSync({ + properties: ['openFile'], + defaultPath: config.ytdlpPath ?? defaultConfig.ytdlpPath, + }); + if (result && result[0]) { + setConfig({ ytdlpPath: result[0] }); + } + }, + }, + { + label: ffmpegLabel, + click() { + const result = dialog.showOpenDialogSync({ + properties: ['openFile'], + defaultPath: config.ytdlpFfmpegPath ?? '', + }); + if (result && result[0]) { + setConfig({ ytdlpFfmpegPath: result[0] }); + } + }, + }, + ], + }, { label: t('plugins.downloader.menu.skip-existing'), type: 'checkbox', diff --git a/src/plugins/downloader/renderer.tsx b/src/plugins/downloader/renderer.tsx old mode 100644 new mode 100755 diff --git a/src/plugins/downloader/style.css b/src/plugins/downloader/style.css old mode 100644 new mode 100755 diff --git a/src/plugins/downloader/templates/download.tsx b/src/plugins/downloader/templates/download.tsx old mode 100644 new mode 100755 diff --git a/src/plugins/downloader/types.ts b/src/plugins/downloader/types.ts old mode 100644 new mode 100755