Merge branch 'local-upstream/master' into option-to-load-captions-selector-on-every-song

This commit is contained in:
Araxeus
2023-03-19 23:52:43 +02:00
21 changed files with 753 additions and 702 deletions

View File

@ -1,11 +0,0 @@
const CHANNEL = "downloader";
const ACTIONS = {
ERROR: "error",
METADATA: "metadata",
PROGRESS: "progress",
};
module.exports = {
CHANNEL: CHANNEL,
ACTIONS: ACTIONS,
};

View File

@ -1,98 +1,484 @@
const { writeFileSync } = require("fs");
const { join } = require("path");
const {
existsSync,
mkdirSync,
createWriteStream,
writeFileSync,
} = require('fs');
const { join } = require('path');
const ID3Writer = require("browser-id3-writer");
const { dialog, ipcMain } = require("electron");
const { fetchFromGenius } = require('../lyrics-genius/back');
const { isEnabled } = require('../../config/plugins');
const { getImage } = require('../../providers/song-info');
const { injectCSS } = require('../utils');
const {
presets,
cropMaxWidth,
getFolder,
setBadge,
sendFeedback: sendFeedback_,
} = require('./utils');
const registerCallback = require("../../providers/song-info");
const { injectCSS, listenAction } = require("../utils");
const { cropMaxWidth } = require("./utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
const { isEnabled } = require("../../config/plugins");
const { getImage } = require("../../providers/song-info");
const { fetchFromGenius } = require("../lyrics-genius/back");
const { ipcMain, app, dialog } = require('electron');
const is = require('electron-is');
const { Innertube, UniversalCache, Utils } = require('youtubei.js');
const ytpl = require('ytpl'); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
const sendError = (win, error) => {
win.setProgressBar(-1); // close progress bar
dialog.showMessageBox({
type: "info",
buttons: ["OK"],
title: "Error in download!",
message: "Argh! Apologies, download failed…",
detail: error.toString(),
});
const filenamify = require('filenamify');
const ID3Writer = require('browser-id3-writer');
const { randomBytes } = require('crypto');
const Mutex = require('async-mutex').Mutex;
const ffmpeg = require('@ffmpeg/ffmpeg').createFFmpeg({
log: false,
logger: () => {}, // console.log,
progress: () => {}, // console.log,
});
const ffmpegMutex = new Mutex();
const cache = {
getCoverBuffer: {
buffer: null,
url: null,
},
};
let nowPlayingMetadata = {};
const config = require('./config');
function handle(win) {
injectCSS(win.webContents, join(__dirname, "style.css"));
registerCallback((info) => {
nowPlayingMetadata = info;
});
/** @type {Innertube} */
let yt;
let win;
let playingUrl = undefined;
listenAction(CHANNEL, (event, action, arg) => {
switch (action) {
case ACTIONS.ERROR: // arg = error
sendError(win, arg);
break;
case ACTIONS.METADATA:
event.returnValue = JSON.stringify(nowPlayingMetadata);
break;
case ACTIONS.PROGRESS: // arg = progress
win.setProgressBar(arg);
break;
default:
console.log("Unknown action: " + action);
}
});
const sendError = (error) => {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge
sendFeedback_(win); // reset feedback
ipcMain.on("add-metadata", async (event, filePath, songBuffer, currentMetadata) => {
let fileBuffer = songBuffer;
const songMetadata = currentMetadata.imageSrcYTPL ? // This means metadata come from ytpl.getInfo();
{
...currentMetadata,
image: cropMaxWidth(await getImage(currentMetadata.imageSrcYTPL))
} :
{ ...nowPlayingMetadata, ...currentMetadata };
console.error(error);
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
title: 'Error in download!',
message: 'Argh! Apologies, download failed…',
detail: `${error.toString()} ${
error.cause ? `\n\n${error.cause.toString()}` : ''
}`,
});
};
try {
const coverBuffer = songMetadata.image && !songMetadata.image.isEmpty() ?
songMetadata.image.toPNG() : null;
module.exports = async (win_) => {
win = win_;
injectCSS(win.webContents, join(__dirname, 'style.css'));
const writer = new ID3Writer(songBuffer);
yt = await Innertube.create({
cache: new UniversalCache(false),
generate_session_locally: true,
});
ipcMain.on('download-song', (_, url) => downloadSong(url));
ipcMain.on('video-src-changed', async (_, data) => {
playingUrl =
JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
});
ipcMain.on('download-playlist-request', async (_event, url) =>
downloadPlaylist(url),
);
};
// Create the metadata tags
writer
.setFrame("TIT2", songMetadata.title)
.setFrame("TPE1", [songMetadata.artist]);
if (coverBuffer) {
writer.setFrame("APIC", {
type: 3,
data: coverBuffer,
description: ""
});
}
if (isEnabled("lyrics-genius")) {
const lyrics = await fetchFromGenius(songMetadata);
if (lyrics) {
writer.setFrame("USLT", {
description: lyrics,
lyrics: lyrics,
});
}
}
writer.addTag();
fileBuffer = Buffer.from(writer.arrayBuffer);
} catch (error) {
sendError(win, error);
}
module.exports.downloadSong = downloadSong;
module.exports.downloadPlaylist = downloadPlaylist;
writeFileSync(filePath, fileBuffer);
// Notify the youtube-dl file
event.reply("add-metadata-done");
});
async function downloadSong(
url,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {},
) {
try {
await downloadSongUnsafe(
url,
playlistFolder,
trackId,
increasePlaylistProgress,
);
} catch (error) {
sendError(error);
}
}
module.exports = handle;
module.exports.sendError = sendError;
async function downloadSongUnsafe(
url,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {},
) {
const sendFeedback = (message, progress) => {
if (!playlistFolder) {
sendFeedback_(win, message);
if (!isNaN(progress)) {
win.setProgressBar(progress);
}
}
};
sendFeedback('Downloading...', 2);
const id = getVideoId(url);
const info = await yt.music.getInfo(id);
const metadata = getMetadata(info);
if (metadata.album === 'N/A') metadata.album = '';
metadata.trackId = trackId;
const dir =
playlistFolder || config.get('downloadFolder') || app.getPath('downloads');
const name = `${metadata.artist ? `${metadata.artist} - ` : ''}${
metadata.title
}`;
const extension = presets[config.get('preset')]?.extension || 'mp3';
const filename = filenamify(`${name}.${extension}`, {
replacement: '_',
maxLength: 255,
});
const filePath = join(dir, filename);
if (config.get('skipExisting') && existsSync(filePath)) {
sendFeedback(null, -1);
return;
}
const download_options = {
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(download_options);
const stream = await info.download(download_options);
console.info(
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`,
);
const iterableStream = Utils.streamToIterable(stream);
if (!existsSync(dir)) {
mkdirSync(dir);
}
if (!presets[config.get('preset')]) {
const fileBuffer = await iterableStreamToMP3(
iterableStream,
metadata,
format.content_length,
sendFeedback,
increasePlaylistProgress,
);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
} else {
const file = createWriteStream(filePath);
let downloaded = 0;
const total = format.content_length;
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,
presets[config.get('preset')]?.ffmpegArgs,
);
sendFeedback(null, -1);
}
sendFeedback(null, -1);
console.info(`Done: "${filePath}"`);
}
async function iterableStreamToMP3(
stream,
metadata,
content_length,
sendFeedback,
increasePlaylistProgress = () => {},
) {
const chunks = [];
let downloaded = 0;
const total = content_length;
for await (const chunk of stream) {
downloaded += chunk.length;
chunks.push(chunk);
const ratio = downloaded / total;
const progress = Math.floor(ratio * 100);
sendFeedback(`Download: ${progress}%`, ratio);
// 15% for download, 85% for conversion
// This is a very rough estimate, trying to make the progress bar look nice
increasePlaylistProgress(ratio * 0.15);
}
sendFeedback('Loading…', 2); // indefinite progress bar after download
const buffer = Buffer.concat(chunks);
const safeVideoName = randomBytes(32).toString('hex');
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
sendFeedback('Preparing file…');
ffmpeg.FS('writeFile', safeVideoName, buffer);
sendFeedback('Converting…');
ffmpeg.setProgress(({ ratio }) => {
sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
increasePlaylistProgress(0.15 + ratio * 0.85);
});
await ffmpeg.run(
'-i',
safeVideoName,
...getFFmpegMetadataArgs(metadata),
`${safeVideoName}.mp3`,
);
sendFeedback('Saving…');
return ffmpeg.FS('readFile', `${safeVideoName}.mp3`);
} catch (e) {
sendError(e);
} finally {
releaseFFmpegMutex();
}
}
async function getCoverBuffer(url) {
const store = cache.getCoverBuffer;
if (store.url === url) {
return store.buffer;
}
store.url = url;
const nativeImage = cropMaxWidth(await getImage(url));
store.buffer =
nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
return store.buffer;
}
async function writeID3(buffer, metadata, sendFeedback) {
try {
sendFeedback('Writing ID3 tags...');
const coverBuffer = await getCoverBuffer(metadata.image);
const writer = new ID3Writer(buffer);
// Create the metadata tags
writer.setFrame('TIT2', metadata.title).setFrame('TPE1', [metadata.artist]);
if (metadata.album) {
writer.setFrame('TALB', metadata.album);
}
if (coverBuffer) {
writer.setFrame('APIC', {
type: 3,
data: coverBuffer,
description: '',
});
}
if (isEnabled('lyrics-genius')) {
const lyrics = await fetchFromGenius(metadata);
if (lyrics) {
writer.setFrame('USLT', {
description: '',
lyrics: lyrics,
});
}
}
if (metadata.trackId) {
writer.setFrame('TRCK', metadata.trackId);
}
writer.addTag();
return Buffer.from(writer.arrayBuffer);
} catch (e) {
sendError(e);
}
}
async function downloadPlaylist(givenUrl) {
try {
givenUrl = new URL(givenUrl);
} catch {
givenUrl = undefined;
}
const playlistId =
getPlaylistID(givenUrl) ||
getPlaylistID(new URL(win.webContents.getURL())) ||
getPlaylistID(new URL(playingUrl));
if (!playlistId) {
sendError(new Error('No playlist ID found'));
return;
}
const sendFeedback = (message) => sendFeedback_(win, message);
console.log(`trying to get playlist ID: '${playlistId}'`);
sendFeedback('Getting playlist info…');
let playlist;
try {
playlist = await ytpl(playlistId, {
limit: config.get('playlistMaxItems') || Infinity,
});
} catch (e) {
sendError(
`Error getting playlist info: make sure it isn\'t a private or "Mixed for you" playlist\n\n${e}`,
);
return;
}
if (playlist.items.length === 0) sendError(new Error('Playlist is empty'));
if (playlist.items.length === 1) {
sendFeedback('Playlist has only one item, downloading it directly');
await downloadSong(playlist.items[0].url);
return;
}
const isAlbum = playlist.title.startsWith('Album - ');
if (isAlbum) {
playlist.title = playlist.title.slice(8);
}
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
const folder = getFolder(config.get('downloadFolder'));
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
if (!config.get('skipExisting')) {
sendError(new Error(`The folder ${playlistFolder} already exists`));
return;
}
} else {
mkdirSync(playlistFolder, { recursive: true });
}
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
title: 'Started Download',
message: `Downloading Playlist "${playlist.title}"`,
detail: `(${playlist.items.length} songs)`,
});
if (is.dev()) {
console.log(
`Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})`,
);
}
win.setProgressBar(2); // starts with indefinite bar
setBadge(playlist.items.length);
let counter = 1;
const progressStep = 1 / playlist.items.length;
const increaseProgress = (itemPercentage) => {
const currentProgress = (counter - 1) / playlist.items.length;
const newProgress = currentProgress + progressStep * itemPercentage;
win.setProgressBar(newProgress);
};
try {
for (const song of playlist.items) {
sendFeedback(`Downloading ${counter}/${playlist.items.length}...`);
const trackId = isAlbum ? counter : undefined;
await downloadSong(
song.url,
playlistFolder,
trackId,
increaseProgress,
).catch((e) =>
sendError(
`Error downloading "${song.author.name} - ${song.title}":\n ${e}`,
),
);
win.setProgressBar(counter / playlist.items.length);
setBadge(playlist.items.length - counter);
counter++;
}
} catch (e) {
sendError(e);
} finally {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge counter
sendFeedback(); // clear feedback
}
}
async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
if (!ffmpeg.isLoaded()) {
await ffmpeg.load();
}
await ffmpeg.run(
'-i',
filePath,
...getFFmpegMetadataArgs(metadata),
...ffmpegArgs,
filePath,
);
} catch (e) {
sendError(e);
} finally {
releaseFFmpegMutex();
}
}
function getFFmpegMetadataArgs(metadata) {
if (!metadata) {
return;
}
return [
...(metadata.title ? ['-metadata', `title=${metadata.title}`] : []),
...(metadata.artist ? ['-metadata', `artist=${metadata.artist}`] : []),
...(metadata.album ? ['-metadata', `album=${metadata.album}`] : []),
...(metadata.trackId ? ['-metadata', `track=${metadata.trackId}`] : []),
];
}
// Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = (aURL) => {
const result =
aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
return result.slice(INVALID_PLAYLIST_MODIFIER.length);
}
return result;
};
const getVideoId = (url) => {
if (typeof url === 'string') {
url = new URL(url);
}
return url.searchParams.get('v');
};
const getMetadata = (info) => ({
id: info.basic_info.id,
title: info.basic_info.title,
artist: info.basic_info.author,
album: info.player_overlays?.browser_media_session?.album?.text,
image: info.basic_info.thumbnail[0].url,
});

View File

@ -0,0 +1,3 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('downloader');
module.exports = { ...config };

View File

@ -2,97 +2,68 @@ const { ipcRenderer } = require("electron");
const { defaultConfig } = require("../../config");
const { getSongMenu } = require("../../providers/dom-elements");
const { ElementFromFile, templatePath, triggerAction } = require("../utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
const { downloadVideoToMP3 } = require("./youtube-dl");
const { ElementFromFile, templatePath } = require("../utils");
let menu = null;
let progress = null;
const downloadButton = ElementFromFile(
templatePath(__dirname, "download.html")
);
let pluginOptions = {};
const observer = new MutationObserver(() => {
let doneFirstLoad = false;
const menuObserver = new MutationObserver(() => {
if (!menu) {
menu = getSongMenu();
if (!menu) return;
}
if (menu.contains(downloadButton)) return;
const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
if (menuUrl && !menuUrl.includes('watch?')) return;
if (!menuUrl?.includes('watch?') && doneFirstLoad) return;
menu.prepend(downloadButton);
progress = document.querySelector("#ytmcustom-download");
if (doneFirstLoad) return;
setTimeout(() => doneFirstLoad ||= true, 500);
});
const reinit = () => {
triggerAction(CHANNEL, ACTIONS.PROGRESS, -1); // closes progress bar
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = "Download";
}
};
const baseUrl = defaultConfig.url;
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld("downloader", {
// download: () => {
global.download = () => {
triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar
let metadata;
let videoUrl = getSongMenu()
// selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint')
?.getAttribute("href");
if (videoUrl) {
if (videoUrl.startsWith('watch?')) {
videoUrl = baseUrl + "/" + videoUrl;
videoUrl = defaultConfig.url + "/" + videoUrl;
}
if (videoUrl.includes('?playlist=')) {
ipcRenderer.send('download-playlist-request', videoUrl);
return;
}
metadata = null;
} else {
metadata = global.songInfo;
videoUrl = metadata.url || window.location.href;
videoUrl = global.songInfo.url || window.location.href;
}
downloadVideoToMP3(
videoUrl,
(feedback, ratio = undefined) => {
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = feedback;
}
if (ratio) {
triggerAction(CHANNEL, ACTIONS.PROGRESS, ratio);
}
},
(error) => {
triggerAction(CHANNEL, ACTIONS.ERROR, error);
reinit();
},
reinit,
pluginOptions,
metadata
);
ipcRenderer.send('download-song', videoUrl);
};
// });
function observeMenu(options) {
pluginOptions = { ...pluginOptions, ...options };
module.exports = () => {
document.addEventListener('apiLoaded', () => {
observer.observe(document.querySelector('ytmusic-popup-container'), {
menuObserver.observe(document.querySelector('ytmusic-popup-container'), {
childList: true,
subtree: true,
});
}, { once: true, passive: true })
}
module.exports = observeMenu;
ipcRenderer.on('downloader-feedback', (_, feedback) => {
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = feedback || "Download";
}
});
};

View File

@ -1,55 +1,24 @@
const { existsSync, mkdirSync } = require("fs");
const { join } = require("path");
const { dialog } = require("electron");
const { dialog, ipcMain } = require("electron");
const is = require("electron-is");
const ytpl = require("ytpl");
const chokidar = require('chokidar');
const filenamify = require('filenamify');
const { setMenuOptions } = require("../../config/plugins");
const { sendError } = require("./back");
const { defaultMenuDownloadLabel, getFolder, presets, setBadge } = require("./utils");
let downloadLabel = defaultMenuDownloadLabel;
let playingUrl = undefined;
let callbackIsRegistered = false;
// Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = aURL => {
const result = aURL?.searchParams.get("list") || aURL?.searchParams.get("playlist");
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
return result.slice(6)
}
return result;
};
module.exports = (win, options) => {
if (!callbackIsRegistered) {
ipcMain.on("video-src-changed", async (_, data) => {
playingUrl = JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
});
ipcMain.on("download-playlist-request", async (_event, url) => downloadPlaylist(url, win, options));
callbackIsRegistered = true;
}
const { downloadPlaylist } = require("./back");
const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils");
const config = require("./config");
module.exports = () => {
return [
{
label: downloadLabel,
click: () => downloadPlaylist(undefined, win, options),
label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(),
},
{
label: "Choose download folder",
click: () => {
let result = dialog.showOpenDialogSync({
const result = dialog.showOpenDialogSync({
properties: ["openDirectory", "createDirectory"],
defaultPath: getFolder(options.downloadFolder),
defaultPath: getFolder(config.get("downloadFolder")),
});
if (result) {
options.downloadFolder = result[0];
setMenuOptions("downloader", options);
config.set("downloadFolder", result[0]);
} // else = user pressed cancel
},
},
@ -58,94 +27,19 @@ module.exports = (win, options) => {
submenu: Object.keys(presets).map((preset) => ({
label: preset,
type: "radio",
checked: config.get("preset") === preset,
click: () => {
options.preset = preset;
setMenuOptions("downloader", options);
config.set("preset", preset);
},
checked: options.preset === preset || presets[preset] === undefined,
})),
},
{
label: "Skip existing files",
type: "checkbox",
checked: config.get("skipExisting"),
click: (item) => {
config.set("skipExisting", item.checked);
},
},
];
};
async function downloadPlaylist(givenUrl, win, options) {
if (givenUrl) {
try {
givenUrl = new URL(givenUrl);
} catch {
givenUrl = undefined;
};
}
const playlistId = getPlaylistID(givenUrl)
|| getPlaylistID(new URL(win.webContents.getURL()))
|| getPlaylistID(new URL(playingUrl));
if (!playlistId) {
sendError(win, new Error("No playlist ID found"));
return;
}
console.log(`trying to get playlist ID: '${playlistId}'`);
let playlist;
try {
playlist = await ytpl(playlistId, {
limit: options.playlistMaxItems || Infinity,
});
} catch (e) {
sendError(win, e);
return;
}
const safePlaylistTitle = filenamify(playlist.title, {replacement: ' '});
const folder = getFolder(options.downloadFolder);
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
sendError(
win,
new Error(`The folder ${playlistFolder} already exists`)
);
return;
}
mkdirSync(playlistFolder, { recursive: true });
dialog.showMessageBox({
type: "info",
buttons: ["OK"],
title: "Started Download",
message: `Downloading Playlist "${playlist.title}"`,
detail: `(${playlist.items.length} songs)`,
});
if (is.dev()) {
console.log(
`Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})`
);
}
win.setProgressBar(2); // starts with indefinite bar
let downloadCount = 0;
setBadge(playlist.items.length);
let dirWatcher = chokidar.watch(playlistFolder);
dirWatcher.on('add', () => {
downloadCount += 1;
if (downloadCount >= playlist.items.length) {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge counter
dirWatcher.close().then(() => (dirWatcher = null));
} else {
win.setProgressBar(downloadCount / playlist.items.length);
setBadge(playlist.items.length - downloadCount);
}
});
playlist.items.forEach((song) => {
win.webContents.send(
"downloader-download-playlist",
song.url,
safePlaylistTitle,
options
);
});
}

View File

@ -1,20 +1,12 @@
const electron = require("electron");
const { app } = require("electron");
const is = require('electron-is');
module.exports.getFolder = customFolder => customFolder || electron.app.getPath("downloads");
module.exports.getFolder = customFolder => customFolder || app.getPath("downloads");
module.exports.defaultMenuDownloadLabel = "Download playlist";
const orderedQualityList = ["maxresdefault", "hqdefault", "mqdefault", "sdddefault"];
module.exports.urlToJPG = (imgUrl, videoId) => {
if (!imgUrl || imgUrl.includes(".jpg")) return imgUrl;
//it will almost never get further than hqdefault
for (const quality of orderedQualityList) {
if (imgUrl.includes(quality)) {
return `https://img.youtube.com/vi/${videoId}/${quality}.jpg`;
}
}
return `https://img.youtube.com/vi/${videoId}/default.jpg`;
}
module.exports.sendFeedback = (win, message) => {
win.webContents.send("downloader-feedback", message);
};
module.exports.cropMaxWidth = (image) => {
const imageSize = image.getSize();
@ -41,6 +33,6 @@ module.exports.presets = {
module.exports.setBadge = n => {
if (is.linux() || is.macOS()) {
electron.app.setBadgeCount(n);
app.setBadgeCount(n);
}
}

View File

@ -1,206 +0,0 @@
const { randomBytes } = require("crypto");
const { join } = require("path");
const Mutex = require("async-mutex").Mutex;
const { ipcRenderer } = require("electron");
const is = require("electron-is");
const filenamify = require("filenamify");
// Workaround for "Automatic publicPath is not supported in this browser"
// See https://github.com/cypress-io/cypress/issues/18435#issuecomment-1048863509
const script = document.createElement("script");
document.body.appendChild(script);
script.src = " "; // single space and not the empty string
// Browser version of FFmpeg (in renderer process) instead of loading @ffmpeg/ffmpeg
// because --js-flags cannot be passed in the main process when the app is packaged
// See https://github.com/electron/electron/issues/22705
const FFmpeg = require("@ffmpeg/ffmpeg/dist/ffmpeg.min");
const ytdl = require("ytdl-core");
const { triggerAction, triggerActionSync } = require("../utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
const { presets, urlToJPG } = require("./utils");
const { cleanupName } = require("../../providers/song-info");
const { createFFmpeg } = FFmpeg;
const ffmpeg = createFFmpeg({
log: false,
logger: () => {}, // console.log,
progress: () => {}, // console.log,
});
const ffmpegMutex = new Mutex();
const downloadVideoToMP3 = async (
videoUrl,
sendFeedback,
sendError,
reinit,
options,
metadata = undefined,
subfolder = ""
) => {
sendFeedback("Downloading…");
if (metadata === null) {
const { videoDetails } = await ytdl.getInfo(videoUrl);
const thumbnails = videoDetails?.thumbnails;
metadata = {
artist:
videoDetails?.media?.artist ||
cleanupName(videoDetails?.author?.name) ||
"",
title: videoDetails?.media?.song || videoDetails?.title || "",
imageSrcYTPL: thumbnails ?
urlToJPG(thumbnails[thumbnails.length - 1].url, videoDetails?.videoId)
: ""
}
}
let videoName = "YouTube Music - Unknown title";
let videoReadableStream;
try {
videoReadableStream = ytdl(videoUrl, {
filter: "audioonly",
quality: "highestaudio",
highWaterMark: 32 * 1024 * 1024, // 32 MB
requestOptions: { maxRetries: 3 },
});
} catch (err) {
sendError(err);
return;
}
const chunks = [];
videoReadableStream
.on("data", (chunk) => {
chunks.push(chunk);
})
.on("progress", (_chunkLength, downloaded, total) => {
const ratio = downloaded / total;
const progress = Math.floor(ratio * 100);
sendFeedback("Download: " + progress + "%", ratio);
})
.on("info", (info, format) => {
videoName = info.videoDetails.title.replaceAll("|", "").toString("ascii");
if (is.dev()) {
console.log(
"Downloading video - name:",
videoName,
"- quality:",
format.audioBitrate + "kbits/s"
);
}
})
.on("error", sendError)
.on("end", async () => {
const buffer = Buffer.concat(chunks);
await toMP3(
videoName,
buffer,
sendFeedback,
sendError,
reinit,
options,
metadata,
subfolder
);
});
};
const toMP3 = async (
videoName,
buffer,
sendFeedback,
sendError,
reinit,
options,
existingMetadata = undefined,
subfolder = ""
) => {
const convertOptions = { ...presets[options.preset], ...options };
const safeVideoName = randomBytes(32).toString("hex");
const extension = convertOptions.extension || "mp3";
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
if (!ffmpeg.isLoaded()) {
sendFeedback("Loading…", 2); // indefinite progress bar after download
await ffmpeg.load();
}
sendFeedback("Preparing file…");
ffmpeg.FS("writeFile", safeVideoName, buffer);
sendFeedback("Converting…");
const metadata = existingMetadata || getMetadata();
await ffmpeg.run(
"-i",
safeVideoName,
...getFFmpegMetadataArgs(metadata),
...(convertOptions.ffmpegArgs || []),
safeVideoName + "." + extension
);
const folder = options.downloadFolder || await ipcRenderer.invoke('getDownloadsFolder');
const name = metadata.title
? `${metadata.artist ? `${metadata.artist} - ` : ""}${metadata.title}`
: videoName;
const filename = filenamify(name + "." + extension, {
replacement: "_",
maxLength: 255,
});
const filePath = join(folder, subfolder, filename);
const fileBuffer = ffmpeg.FS("readFile", safeVideoName + "." + extension);
// Add the metadata
sendFeedback("Adding metadata…");
ipcRenderer.send("add-metadata", filePath, fileBuffer, {
artist: metadata.artist,
title: metadata.title,
imageSrcYTPL: metadata.imageSrcYTPL
});
ipcRenderer.once("add-metadata-done", reinit);
} catch (e) {
sendError(e);
} finally {
releaseFFmpegMutex();
}
};
const getMetadata = () => {
return JSON.parse(triggerActionSync(CHANNEL, ACTIONS.METADATA));
};
const getFFmpegMetadataArgs = (metadata) => {
if (!metadata) {
return;
}
return [
...(metadata.title ? ["-metadata", `title=${metadata.title}`] : []),
...(metadata.artist ? ["-metadata", `artist=${metadata.artist}`] : []),
];
};
module.exports = {
downloadVideoToMP3,
};
ipcRenderer.on(
"downloader-download-playlist",
(_, url, playlistFolder, options) => {
downloadVideoToMP3(
url,
() => {},
(error) => {
triggerAction(CHANNEL, ACTIONS.ERROR, error);
},
() => {},
options,
null,
playlistFolder
);
}
);

View File

@ -12,11 +12,18 @@
height: 75px !important;
}
/* fixes top gap between nav-bar and browse-page */
/* fix top gap between nav-bar and browse-page */
#browse-page {
padding-top: 0 !important;
}
/* fix navbar hiding library items */
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_LIBRARY_CONTENT_LANDING_PAGE"],
ytmusic-section-list-renderer[page-type="MUSIC_PAGE_TYPE_PRIVATELY_OWNED_CONTENT_LANDING_PAGE"] {
top: 50px;
position: relative;
}
/* remove window dragging for nav bar (conflict with titlebar drag) */
ytmusic-nav-bar,
.tab-titleiron-icon,

View File

@ -7,8 +7,13 @@ const fetch = require("node-fetch");
const { cleanupName } = require("../../providers/song-info");
const { injectCSS } = require("../utils");
let eastAsianChars = /\p{Script=Han}|\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false;
module.exports = async (win) => {
module.exports = async (win, options) => {
if(options.romanizedLyrics) {
revRomanized = true;
}
injectCSS(win.webContents, join(__dirname, "style.css"));
ipcMain.on("search-genius-lyrics", async (event, extractedSongInfo) => {
@ -17,10 +22,41 @@ module.exports = async (win) => {
});
};
const toggleRomanized = () => {
revRomanized = !revRomanized;
};
const fetchFromGenius = async (metadata) => {
const queryString = `${cleanupName(metadata.artist)} ${cleanupName(
metadata.title
)}`;
const songTitle = `${cleanupName(metadata.title)}`;
const songArtist = `${cleanupName(metadata.artist)}`;
let lyrics;
/* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal
Genius Lyrics behavior is observed.
*/
let hasAsianChars = false;
if (revRomanized && (eastAsianChars.test(songTitle) || eastAsianChars.test(songArtist))) {
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
hasAsianChars = true;
} else {
lyrics = await getLyricsList(`${songArtist} ${songTitle}`);
}
/* If the romanization toggle is on, and we did not detect any characters in the title or artist, we do a check
for characters in the lyrics themselves. If this check proves true, we search for Romanized lyrics.
*/
if(revRomanized && !hasAsianChars && eastAsianChars.test(lyrics)) {
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
}
return lyrics;
};
/**
* Fetches a JSON of songs which is then parsed and passed into getLyrics to get the lyrical content of the first song
* @param {*} queryString
* @returns The lyrics of the first song found using the Genius-Lyrics API
*/
const getLyricsList = async (queryString) => {
let response = await fetch(
`https://genius.com/api/search/multi?per_page=5&q=${encodeURIComponent(queryString)}`
);
@ -28,6 +64,9 @@ const fetchFromGenius = async (metadata) => {
return null;
}
/* Fetch the first URL with the api, giving a collection of song results.
Pick the first song, parsing the json given by the API.
*/
const info = await response.json();
let url = "";
try {
@ -36,16 +75,23 @@ const fetchFromGenius = async (metadata) => {
} catch {
return null;
}
let lyrics = await getLyrics(url);
return lyrics;
}
if (is.dev()) {
console.log("Fetching lyrics from Genius:", url);
}
/**
*
* @param {*} url
* @returns The lyrics of the song URL provided, null if none
*/
const getLyrics = async (url) => {
response = await fetch(url);
if (!response.ok) {
return null;
}
if (is.dev()) {
console.log("Fetching lyrics from Genius:", url);
}
const html = await response.text();
const lyrics = convert(html, {
baseElements: {
@ -64,8 +110,8 @@ const fetchFromGenius = async (metadata) => {
},
},
});
return lyrics;
};
module.exports.fetchFromGenius = fetchFromGenius;
module.exports.toggleRomanized = toggleRomanized;
module.exports.fetchFromGenius = fetchFromGenius;

View File

@ -0,0 +1,17 @@
const { setOptions } = require("../../config/plugins");
const { toggleRomanized } = require("./back");
module.exports = (win, options, refreshMenu) => {
return [
{
label: "Romanized Lyrics",
type: "checkbox",
checked: options.romanizedLyrics,
click: (item) => {
options.romanizedLyrics = item.checked;
setOptions('lyrics-genius', options);
toggleRomanized();
},
},
];
};