feat(downloader): Added support for audio format auto-detection (#1310)

This commit is contained in:
JellyBrick
2023-10-14 03:42:10 +09:00
committed by GitHub
parent a62cafb601
commit 308ac38e6b
6 changed files with 195 additions and 92 deletions

View File

@ -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,
},

View File

@ -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<Record<string, unknown>>, plugin: k
};
const migrations = {
'>=2.1.0'(store: Conf<Record<string, unknown>>) {
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<Record<string, unknown>>) {
setDefaultPluginOptions(store, 'visualizer');

View File

@ -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<Uint8Array, void>,
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 [];

View File

@ -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);
},
})),
},

116
plugins/downloader/types.ts Normal file
View File

@ -0,0 +1,116 @@
export interface Preset {
extension?: string | null;
ffmpegArgs: string[];
}
// Presets for FFmpeg
export const DefaultPresetList: Record<string, Preset> = {
'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: '' },
];

View File

@ -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);