diff --git a/config/defaults.ts b/config/defaults.ts index c11d02ef..0b9fe0d9 100644 --- a/config/defaults.ts +++ b/config/defaults.ts @@ -1,5 +1,7 @@ import { blockers } from '../plugins/adblocker/blocker-types'; +import { DefaultPresetList } from '../plugins/downloader/types'; + export interface WindowSizeConfig { width: number; height: number; @@ -111,9 +113,9 @@ const defaultConfig = { }, 'downloader': { enabled: false, - ffmpegArgs: ['-b:a', '256k'], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s downloadFolder: undefined as string | undefined, // Custom download folder (absolute path) - preset: 'mp3', + selectedPreset: 'mp3 (256kbps)', // Selected preset + customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets skipExisting: false, playlistMaxItems: undefined as number | undefined, }, diff --git a/config/store.ts b/config/store.ts index 7ed38de4..e4ef99fd 100644 --- a/config/store.ts +++ b/config/store.ts @@ -4,6 +4,8 @@ import is from 'electron-is'; import defaults from './defaults'; +import { DefaultPresetList, type Preset } from '../plugins/downloader/types'; + const getDefaults = () => { if (is.windows()) { defaults.plugins['in-app-menu'].enabled = true; @@ -18,6 +20,26 @@ const setDefaultPluginOptions = (store: Conf>, plugin: k }; const migrations = { + '>=2.1.0'(store: Conf>) { + const originalPreset = store.get('plugins.downloader.preset') as string | undefined; + if (originalPreset) { + if (originalPreset !== 'opus') { + store.set('plugins.downloader.selectedPreset', 'Custom'); + store.set('plugins.downloader.customPresetSetting', { + extension: 'mp3', + ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? DefaultPresetList['mp3 (256kbps)'].ffmpegArgs, + } satisfies Preset); + } else { + store.set('plugins.downloader.selectedPreset', 'Source'); + store.set('plugins.downloader.customPresetSetting', { + extension: null, + ffmpegArgs: store.get('plugins.downloader.ffmpegArgs') as string[] ?? [], + } satisfies Preset); + } + store.delete('plugins.downloader.preset'); + store.delete('plugins.downloader.ffmpegArgs'); + } + }, '>=1.20.0'(store: Conf>) { setDefaultPluginOptions(store, 'visualizer'); diff --git a/plugins/downloader/back.ts b/plugins/downloader/back.ts index 69a3d173..874d105f 100644 --- a/plugins/downloader/back.ts +++ b/plugins/downloader/back.ts @@ -8,12 +8,11 @@ import is from 'electron-is'; import filenamify from 'filenamify'; import { Mutex } from 'async-mutex'; import { createFFmpeg } from '@ffmpeg.wasm/main'; - import NodeID3, { TagConstants } from 'node-id3'; -import { cropMaxWidth, getFolder, presets, sendFeedback as sendFeedback_, setBadge } from './utils'; - +import { cropMaxWidth, getFolder, sendFeedback as sendFeedback_, setBadge } from './utils'; import config from './config'; +import { YoutubeFormatList, type Preset, DefaultPresetList } from './types'; import style from './style.css'; @@ -221,13 +220,32 @@ async function downloadSongUnsafe( ); } - const preset = config.get('preset') ?? 'mp3'; - let presetSetting: { extension: string; ffmpegArgs: string[] } | null = null; - if (preset === 'opus') { - presetSetting = presets[preset]; + const selectedPreset = config.get('selectedPreset') ?? 'mp3 (256kbps)'; + let presetSetting: Preset; + if (selectedPreset === 'Custom') { + presetSetting = config.get('customPresetSetting') ?? DefaultPresetList['Custom']; + } else if (selectedPreset === 'Source') { + presetSetting = DefaultPresetList['Source']; + } else { + presetSetting = DefaultPresetList['mp3 (256kbps)']; } - let filename = filenamify(`${name}.${presetSetting?.extension ?? 'mp3'}`, { + const downloadOptions: FormatOptions = { + type: 'audio', // Audio, video or video+audio + quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on. + format: 'any', // Media container format + }; + + const format = info.chooseFormat(downloadOptions); + + let targetFileExtension: string; + if (!presetSetting?.extension) { + targetFileExtension = YoutubeFormatList.find((it) => it.itag === format.itag)?.container ?? 'mp3'; + } else { + targetFileExtension = presetSetting?.extension ?? 'mp3'; + } + + let filename = filenamify(`${name}.${targetFileExtension}`, { replacement: '_', maxLength: 255, }); @@ -241,13 +259,6 @@ async function downloadSongUnsafe( return; } - const downloadOptions: FormatOptions = { - type: 'audio', // Audio, video or video+audio - quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on. - format: 'any', // Media container format - }; - - const format = info.chooseFormat(downloadOptions); const stream = await info.download(downloadOptions); console.info( @@ -260,39 +271,20 @@ async function downloadSongUnsafe( mkdirSync(dir); } - const ffmpegArgs = config.get('ffmpegArgs'); + const fileBuffer = await iterableStreamToTargetFile( + iterableStream, + targetFileExtension, + metadata, + presetSetting?.ffmpegArgs ?? [], + format.content_length ?? 0, + sendFeedback, + increasePlaylistProgress, + ); - if (presetSetting && presetSetting?.extension !== 'mp3') { - const file = createWriteStream(filePath); - let downloaded = 0; - const total: number = format.content_length ?? 1; - - for await (const chunk of iterableStream) { - downloaded += chunk.length; - const ratio = downloaded / total; - const progress = Math.floor(ratio * 100); - sendFeedback(`Download: ${progress}%`, ratio); - increasePlaylistProgress(ratio); - file.write(chunk); - } - - await ffmpegWriteTags( - filePath, - metadata, - presetSetting.ffmpegArgs, - ffmpegArgs, - ); - sendFeedback(null, -1); - } else { - const fileBuffer = await iterableStreamToMP3( - iterableStream, - metadata, - ffmpegArgs, - format.content_length ?? 0, - sendFeedback, - increasePlaylistProgress, - ); - if (fileBuffer) { + if (fileBuffer) { + if (targetFileExtension !== 'mp3') { + createWriteStream(filePath).write(fileBuffer); + } else { const buffer = await writeID3(Buffer.from(fileBuffer), metadata, sendFeedback); if (buffer) { writeFileSync(filePath, buffer); @@ -304,10 +296,11 @@ async function downloadSongUnsafe( console.info(`Done: "${filePath}"`); } -async function iterableStreamToMP3( +async function iterableStreamToTargetFile( stream: AsyncGenerator, + extension: string, metadata: CustomSongInfo, - ffmpegArgs: string[], + presetFfmpegArgs: string[], contentLength: number, sendFeedback: (str: string, value?: number) => void, increasePlaylistProgress: (value: number) => void = () => { @@ -347,13 +340,14 @@ async function iterableStreamToMP3( increasePlaylistProgress(0.15 + (ratio * 0.85)); }); + const safeVideoNameWithExtension = `${safeVideoName}.${extension}`; try { await ffmpeg.run( '-i', safeVideoName, - ...ffmpegArgs, + ...presetFfmpegArgs, ...getFFmpegMetadataArgs(metadata), - `${safeVideoName}.mp3`, + safeVideoNameWithExtension, ); } finally { ffmpeg.FS('unlink', safeVideoName); @@ -362,9 +356,9 @@ async function iterableStreamToMP3( sendFeedback('Saving…'); try { - return ffmpeg.FS('readFile', `${safeVideoName}.mp3`); + return ffmpeg.FS('readFile', safeVideoNameWithExtension); } finally { - ffmpeg.FS('unlink', `${safeVideoName}.mp3`); + ffmpeg.FS('unlink', safeVideoNameWithExtension); } } catch (error: unknown) { sendError(error as Error, safeVideoName); @@ -544,29 +538,6 @@ export async function downloadPlaylist(givenUrl?: string | URL) { } } -async function ffmpegWriteTags(filePath: string, metadata: CustomSongInfo, presetFFmpegArgs: string[] = [], ffmpegArgs: string[] = []) { - const releaseFFmpegMutex = await ffmpegMutex.acquire(); - - try { - if (!ffmpeg.isLoaded()) { - await ffmpeg.load(); - } - - await ffmpeg.run( - '-i', - filePath, - ...getFFmpegMetadataArgs(metadata), - ...presetFFmpegArgs, - ...ffmpegArgs, - filePath, - ); - } catch (error: unknown) { - sendError(error as Error); - } finally { - releaseFFmpegMutex(); - } -} - function getFFmpegMetadataArgs(metadata: CustomSongInfo) { if (!metadata) { return []; diff --git a/plugins/downloader/menu.ts b/plugins/downloader/menu.ts index c49a6389..d96ded15 100644 --- a/plugins/downloader/menu.ts +++ b/plugins/downloader/menu.ts @@ -1,7 +1,8 @@ import { dialog } from 'electron'; import { downloadPlaylist } from './back'; -import { defaultMenuDownloadLabel, getFolder, presets } from './utils'; +import { defaultMenuDownloadLabel, getFolder } from './utils'; +import { DefaultPresetList } from './types'; import config from './config'; import { MenuTemplate } from '../../menu'; @@ -25,12 +26,12 @@ export default (): MenuTemplate => [ }, { label: 'Presets', - submenu: Object.keys(presets).map((preset) => ({ + submenu: Object.keys(DefaultPresetList).map((preset) => ({ label: preset, type: 'radio', - checked: config.get('preset') === preset, + checked: config.get('selectedPreset') === preset, click() { - config.set('preset', preset); + config.set('selectedPreset', preset); }, })), }, diff --git a/plugins/downloader/types.ts b/plugins/downloader/types.ts new file mode 100644 index 00000000..175fc015 --- /dev/null +++ b/plugins/downloader/types.ts @@ -0,0 +1,116 @@ +export interface Preset { + extension?: string | null; + ffmpegArgs: string[]; +} + +// Presets for FFmpeg +export const DefaultPresetList: Record = { + 'mp3 (256kbps)': { + extension: 'mp3', + ffmpegArgs: ['-b:a', '256k'], + }, + 'Source': { + extension: undefined, + ffmpegArgs: ['-acodec', 'copy'], + }, + 'Custom': { + extension: null, + ffmpegArgs: [], + } +}; + +export interface YouTubeFormat { + itag: number; + container: string; + content: string; + resolution: string; + bitrate: string; + range: string; + vrOr3D: string; +} + +// converted from https://gist.github.com/sidneys/7095afe4da4ae58694d128b1034e01e2#file-youtube_format_code_itag_list-md +export const YoutubeFormatList: YouTubeFormat[] = [ + { itag: 5, container: 'flv', content: 'audio/video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 6, container: 'flv', content: 'audio/video', resolution: '270p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 17, container: '3gp', content: 'audio/video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 18, container: 'mp4', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 22, container: 'mp4', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 34, container: 'flv', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 35, container: 'flv', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 36, container: '3gp', content: 'audio/video', resolution: '180p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 37, container: 'mp4', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 38, container: 'mp4', content: 'audio/video', resolution: '3072p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 43, container: 'webm', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 44, container: 'webm', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 45, container: 'webm', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 46, container: 'webm', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 82, container: 'mp4', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 83, container: 'mp4', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 84, container: 'mp4', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 85, container: 'mp4', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 91, container: 'hls', content: 'audio/video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 92, container: 'hls', content: 'audio/video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 93, container: 'hls', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 94, container: 'hls', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 95, container: 'hls', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 96, container: 'hls', content: 'audio/video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '-' }, + { itag: 100, container: 'webm', content: 'audio/video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 101, container: 'webm', content: 'audio/video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 102, container: 'webm', content: 'audio/video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '3D' }, + { itag: 132, container: 'hls', content: 'audio/video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 133, container: 'mp4', content: 'video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 134, container: 'mp4', content: 'video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 135, container: 'mp4', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 136, container: 'mp4', content: 'video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 137, container: 'mp4', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 138, container: 'mp4', content: 'video', resolution: '2160p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 139, container: 'm4a', content: 'audio', resolution: '-', bitrate: '48k', range: '-', vrOr3D: '' }, + { itag: 140, container: 'm4a', content: 'audio', resolution: '-', bitrate: '128k', range: '-', vrOr3D: '' }, + { itag: 141, container: 'm4a', content: 'audio', resolution: '-', bitrate: '256k', range: '-', vrOr3D: '' }, + { itag: 151, container: 'hls', content: 'audio/video', resolution: '72p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 160, container: 'mp4', content: 'video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 167, container: 'webm', content: 'video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 168, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 169, container: 'webm', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 171, container: 'webm', content: 'audio', resolution: '-', bitrate: '128k', range: '-', vrOr3D: '' }, + { itag: 218, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 219, container: 'webm', content: 'video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 242, container: 'webm', content: 'video', resolution: '240p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 243, container: 'webm', content: 'video', resolution: '360p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 244, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 245, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 246, container: 'webm', content: 'video', resolution: '480p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 247, container: 'webm', content: 'video', resolution: '720p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 248, container: 'webm', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 249, container: 'webm', content: 'audio', resolution: '-', bitrate: '50k', range: '-', vrOr3D: '' }, + { itag: 250, container: 'webm', content: 'audio', resolution: '-', bitrate: '70k', range: '-', vrOr3D: '' }, + { itag: 251, container: 'webm', content: 'audio', resolution: '-', bitrate: '160k', range: '-', vrOr3D: '' }, + { itag: 264, container: 'mp4', content: 'video', resolution: '1440p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 266, container: 'mp4', content: 'video', resolution: '2160p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 271, container: 'webm', content: 'video', resolution: '1440p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 272, container: 'webm', content: 'video', resolution: '4320p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 278, container: 'webm', content: 'video', resolution: '144p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 298, container: 'mp4', content: 'video', resolution: '720p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 299, container: 'mp4', content: 'video', resolution: '1080p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 302, container: 'webm', content: 'video', resolution: '720p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 303, container: 'webm', content: 'video', resolution: '1080p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 308, container: 'webm', content: 'video', resolution: '1440p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 313, container: 'webm', content: 'video', resolution: '2160p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 315, container: 'webm', content: 'video', resolution: '2160p60', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 330, container: 'webm', content: 'video', resolution: '144p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 331, container: 'webm', content: 'video', resolution: '240p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 332, container: 'webm', content: 'video', resolution: '360p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 333, container: 'webm', content: 'video', resolution: '480p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 334, container: 'webm', content: 'video', resolution: '720p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 335, container: 'webm', content: 'video', resolution: '1080p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 336, container: 'webm', content: 'video', resolution: '1440p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 337, container: 'webm', content: 'video', resolution: '2160p60', bitrate: '-', range: 'hdr', vrOr3D: '' }, + { itag: 272, container: 'webm', content: 'video', resolution: '2880p/4320p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 399, container: 'mp4', content: 'video', resolution: '1080p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 400, container: 'mp4', content: 'video', resolution: '1440p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 401, container: 'mp4', content: 'video', resolution: '2160p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 402, container: 'mp4', content: 'video', resolution: '2880p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 571, container: 'mp4', content: 'video', resolution: '3840p', bitrate: '-', range: '-', vrOr3D: '' }, + { itag: 702, container: 'mp4', content: 'video', resolution: '3840p', bitrate: '-', range: '-', vrOr3D: '' }, +]; diff --git a/plugins/downloader/utils.ts b/plugins/downloader/utils.ts index 7de1f096..feabc52a 100644 --- a/plugins/downloader/utils.ts +++ b/plugins/downloader/utils.ts @@ -10,7 +10,7 @@ export const sendFeedback = (win: BrowserWindow, message?: unknown) => { export const cropMaxWidth = (image: Electron.NativeImage) => { const imageSize = image.getSize(); - // Standart youtube artwork width with margins from both sides is 280 + 720 + 280 + // Standart YouTube artwork width with margins from both sides is 280 + 720 + 280 if (imageSize.width === 1280 && imageSize.height === 720) { return image.crop({ x: 280, @@ -23,15 +23,6 @@ export const cropMaxWidth = (image: Electron.NativeImage) => { return image; }; -// Presets for FFmpeg -export const presets = { - 'None (defaults to mp3)': undefined, - 'opus': { - extension: 'opus', - ffmpegArgs: ['-acodec', 'libopus'], - }, -}; - export const setBadge = (n: number) => { if (is.linux() || is.macOS()) { app.setBadgeCount(n);