This commit is contained in:
Araxeus
2023-03-12 20:00:10 +02:00
parent 83abbdb25a
commit b652a011a5
2 changed files with 370 additions and 320 deletions

View File

@ -1,15 +1,26 @@
const { existsSync, mkdirSync, createWriteStream, writeFileSync } = require('fs'); const {
existsSync,
mkdirSync,
createWriteStream,
writeFileSync,
} = require("fs");
const { join } = require("path"); const { join } = require("path");
const { fetchFromGenius } = require("../lyrics-genius/back"); const { fetchFromGenius } = require("../lyrics-genius/back");
const { isEnabled } = require("../../config/plugins"); const { isEnabled } = require("../../config/plugins");
const { getImage } = require("../../providers/song-info"); const { getImage } = require("../../providers/song-info");
const { injectCSS } = require("../utils"); const { injectCSS } = require("../utils");
const { presets, cropMaxWidth, getFolder, setBadge, sendFeedback: sendFeedback_ } = require('./utils'); const {
presets,
cropMaxWidth,
getFolder,
setBadge,
sendFeedback: sendFeedback_,
} = require("./utils");
const { ipcMain, app, dialog } = require("electron"); const { ipcMain, app, dialog } = require("electron");
const is = require("electron-is"); const is = require("electron-is");
const { Innertube, UniversalCache, Utils } = require('youtubei.js'); const { Innertube, UniversalCache, Utils } = require("youtubei.js");
const ytpl = require("ytpl"); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid const ytpl = require("ytpl"); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
const filenamify = require("filenamify"); const filenamify = require("filenamify");
@ -27,8 +38,8 @@ const cache = {
getCoverBuffer: { getCoverBuffer: {
buffer: null, buffer: null,
url: null, url: null,
} },
} };
const config = require("./config"); const config = require("./config");
@ -42,7 +53,6 @@ const sendError = (error) => {
setBadge(0); // close badge setBadge(0); // close badge
sendFeedback_(win); // reset feedback sendFeedback_(win); // reset feedback
console.error(error); console.error(error);
dialog.showMessageBox({ dialog.showMessageBox({
type: "info", type: "info",
@ -58,17 +68,28 @@ module.exports = async (win_, options) => {
config.init(options); config.init(options);
injectCSS(win.webContents, join(__dirname, "style.css")); injectCSS(win.webContents, join(__dirname, "style.css"));
yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true }); yt = await Innertube.create({
cache: new UniversalCache(false),
generate_session_locally: true,
});
ipcMain.on("download-song", (_, url) => downloadSong(url)); ipcMain.on("download-song", (_, url) => downloadSong(url));
ipcMain.on("video-src-changed", async (_, data) => { ipcMain.on("video-src-changed", async (_, data) => {
playingUrl = JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical; playingUrl =
JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
}); });
ipcMain.on("download-playlist-request", async (_event, url) => downloadPlaylist(url)); ipcMain.on("download-playlist-request", async (_event, url) =>
downloadPlaylist(url),
);
}; };
module.exports.downloadSong = downloadSong; module.exports.downloadSong = downloadSong;
async function downloadSong(url, playlistFolder = undefined, trackId = undefined, increasePlaylistProgress = () => { }) { async function downloadSong(
url,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {},
) {
const sendFeedback = (message, progress) => { const sendFeedback = (message, progress) => {
if (!playlistFolder) { if (!playlistFolder) {
sendFeedback_(win, message); sendFeedback_(win, message);
@ -78,19 +99,22 @@ async function downloadSong(url, playlistFolder = undefined, trackId = undefined
} }
}; };
sendFeedback(`Downloading...`, 2); sendFeedback("Downloading...", 2);
const id = getVideoId(url); const id = getVideoId(url);
const info = await yt.music.getInfo(id); const info = await yt.music.getInfo(id);
const metadata = getMetadata(info); const metadata = getMetadata(info);
if (metadata.album === 'N/A') metadata.album = ''; if (metadata.album === "N/A") metadata.album = "";
metadata.trackId = trackId; metadata.trackId = trackId;
const dir = playlistFolder || config.get('downloadFolder') || app.getPath("downloads"); const dir =
const name = `${metadata.artist ? `${metadata.artist} - ` : ""}${metadata.title}`; playlistFolder || config.get("downloadFolder") || app.getPath("downloads");
const name = `${metadata.artist ? `${metadata.artist} - ` : ""}${
metadata.title
}`;
const extension = presets[config.get('preset')]?.extension || 'mp3'; const extension = presets[config.get("preset")]?.extension || "mp3";
const filename = filenamify(`${name}.${extension}`, { const filename = filenamify(`${name}.${extension}`, {
replacement: "_", replacement: "_",
@ -98,21 +122,23 @@ async function downloadSong(url, playlistFolder = undefined, trackId = undefined
}); });
const filePath = join(dir, filename); const filePath = join(dir, filename);
if (config.get('skipExisting') && existsSync(filePath)) { if (config.get("skipExisting") && existsSync(filePath)) {
sendFeedback(null, -1); sendFeedback(null, -1);
return; return;
} }
const download_options = { const download_options = {
type: 'audio', // audio, video or video+audio type: "audio", // audio, video or video+audio
quality: 'best', // best, bestefficiency, 144p, 240p, 480p, 720p and so on. quality: "best", // best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'any' // media container format format: "any", // media container format
}; };
const format = info.chooseFormat(download_options); const format = info.chooseFormat(download_options);
const stream = await info.download(download_options); const stream = await info.download(download_options);
console.info(`Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`); console.info(
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`,
);
const iterableStream = Utils.streamToIterable(stream); const iterableStream = Utils.streamToIterable(stream);
@ -120,23 +146,33 @@ async function downloadSong(url, playlistFolder = undefined, trackId = undefined
mkdirSync(dir); mkdirSync(dir);
} }
if (!presets[config.get('preset')]) { if (!presets[config.get("preset")]) {
const fileBuffer = await iterableStreamToMP3(iterableStream, metadata, format.content_length, sendFeedback, increasePlaylistProgress); const fileBuffer = await iterableStreamToMP3(
iterableStream,
metadata,
format.content_length,
sendFeedback,
increasePlaylistProgress,
);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback)); writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
} else { } else {
const file = createWriteStream(filePath); const file = createWriteStream(filePath);
let downloaded = 0; let downloaded = 0;
let total = format.content_length; const total = format.content_length;
for await (const chunk of iterableStream) { for await (const chunk of iterableStream) {
downloaded += chunk.length; downloaded += chunk.length;
const ratio = downloaded / total; const ratio = downloaded / total;
const progress = Math.floor(ratio * 100); const progress = Math.floor(ratio * 100);
sendFeedback("Download: " + progress + "%", ratio); sendFeedback(`Download: ${progress}%`, ratio);
increasePlaylistProgress(ratio); increasePlaylistProgress(ratio);
file.write(chunk); file.write(chunk);
} }
await ffmpegWriteTags(filePath, metadata, presets[config.get('preset')]?.ffmpegArgs); await ffmpegWriteTags(
filePath,
metadata,
presets[config.get("preset")]?.ffmpegArgs,
);
sendFeedback(null, -1); sendFeedback(null, -1);
} }
@ -144,16 +180,22 @@ async function downloadSong(url, playlistFolder = undefined, trackId = undefined
console.info(`Done: "${filePath}"`); console.info(`Done: "${filePath}"`);
} }
async function iterableStreamToMP3(stream, metadata, content_length, sendFeedback, increasePlaylistProgress = () => { }) { async function iterableStreamToMP3(
stream,
metadata,
content_length,
sendFeedback,
increasePlaylistProgress = () => {},
) {
const chunks = []; const chunks = [];
let downloaded = 0; let downloaded = 0;
let total = content_length; const total = content_length;
for await (const chunk of stream) { for await (const chunk of stream) {
downloaded += chunk.length; downloaded += chunk.length;
chunks.push(chunk); chunks.push(chunk);
const ratio = downloaded / total; const ratio = downloaded / total;
const progress = Math.floor(ratio * 100); const progress = Math.floor(ratio * 100);
sendFeedback("Download: " + progress + "%", ratio); sendFeedback(`Download: ${progress}%`, ratio);
// 15% for download, 85% for conversion // 15% for download, 85% for conversion
// This is a very rough estimate, trying to make the progress bar look nice // This is a very rough estimate, trying to make the progress bar look nice
increasePlaylistProgress(ratio * 0.15); increasePlaylistProgress(ratio * 0.15);
@ -175,7 +217,7 @@ async function iterableStreamToMP3(stream, metadata, content_length, sendFeedbac
sendFeedback("Converting…"); sendFeedback("Converting…");
ffmpeg.setProgress(({ ratio }) => { ffmpeg.setProgress(({ ratio }) => {
sendFeedback("Converting: " + Math.floor(ratio * 100) + "%", ratio); sendFeedback(`Converting: ${Math.floor(ratio * 100)}%`, ratio);
increasePlaylistProgress(0.15 + ratio * 0.85); increasePlaylistProgress(0.15 + ratio * 0.85);
}); });
@ -183,12 +225,12 @@ async function iterableStreamToMP3(stream, metadata, content_length, sendFeedbac
"-i", "-i",
safeVideoName, safeVideoName,
...getFFmpegMetadataArgs(metadata), ...getFFmpegMetadataArgs(metadata),
safeVideoName + ".mp3" `${safeVideoName}.mp3`,
); );
sendFeedback("Saving…"); sendFeedback("Saving…");
return ffmpeg.FS("readFile", safeVideoName + ".mp3"); return ffmpeg.FS("readFile", `${safeVideoName}.mp3`);
} catch (e) { } catch (e) {
sendError(e); sendError(e);
} finally { } finally {
@ -204,8 +246,8 @@ async function getCoverBuffer(url) {
store.url = url; store.url = url;
const nativeImage = cropMaxWidth(await getImage(url)); const nativeImage = cropMaxWidth(await getImage(url));
store.buffer = nativeImage && !nativeImage.isEmpty() ? store.buffer =
nativeImage.toPNG() : null; nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
return store.buffer; return store.buffer;
} }
@ -219,9 +261,7 @@ async function writeID3(buffer, metadata, sendFeedback) {
const writer = new ID3Writer(buffer); const writer = new ID3Writer(buffer);
// Create the metadata tags // Create the metadata tags
writer writer.setFrame("TIT2", metadata.title).setFrame("TPE1", [metadata.artist]);
.setFrame("TIT2", metadata.title)
.setFrame("TPE1", [metadata.artist]);
if (metadata.album) { if (metadata.album) {
writer.setFrame("TALB", metadata.album); writer.setFrame("TALB", metadata.album);
} }
@ -236,7 +276,7 @@ async function writeID3(buffer, metadata, sendFeedback) {
const lyrics = await fetchFromGenius(metadata); const lyrics = await fetchFromGenius(metadata);
if (lyrics) { if (lyrics) {
writer.setFrame("USLT", { writer.setFrame("USLT", {
description: '', description: "",
lyrics: lyrics, lyrics: lyrics,
}); });
} }
@ -257,28 +297,31 @@ async function downloadPlaylist(givenUrl) {
givenUrl = new URL(givenUrl); givenUrl = new URL(givenUrl);
} catch { } catch {
givenUrl = undefined; givenUrl = undefined;
};
} }
const playlistId = getPlaylistID(givenUrl) }
|| getPlaylistID(new URL(win.webContents.getURL())) const playlistId =
|| getPlaylistID(new URL(playingUrl)); getPlaylistID(givenUrl) ||
getPlaylistID(new URL(win.webContents.getURL())) ||
getPlaylistID(new URL(playingUrl));
if (!playlistId) { if (!playlistId) {
sendError(new Error("No playlist ID found")); sendError(new Error("No playlist ID found"));
return; return;
} }
const sendFeedback = message => sendFeedback_(win, message); const sendFeedback = (message) => sendFeedback_(win, message);
console.log(`trying to get playlist ID: '${playlistId}'`); console.log(`trying to get playlist ID: '${playlistId}'`);
sendFeedback("Getting playlist info…"); sendFeedback("Getting playlist info…");
let playlist; let playlist;
try { try {
playlist = await ytpl(playlistId, { playlist = await ytpl(playlistId, {
limit: config.get('playlistMaxItems') || Infinity, limit: config.get("playlistMaxItems") || Infinity,
}); });
} catch (e) { } catch (e) {
sendError("Error getting playlist info: make sure it isn't a private or \"Mixed for you\" playlist\n\n" + e); sendError(
`Error getting playlist info: make sure it isn\'t a private or "Mixed for you" playlist\n\n${e}`,
);
return; return;
} }
if (playlist.items.length === 0) sendError(new Error("Playlist is empty")); if (playlist.items.length === 0) sendError(new Error("Playlist is empty"));
@ -287,16 +330,16 @@ async function downloadPlaylist(givenUrl) {
await downloadSong(playlist.items[0].url); await downloadSong(playlist.items[0].url);
return; return;
} }
let isAlbum = playlist.title.startsWith('Album - '); const isAlbum = playlist.title.startsWith("Album - ");
if (isAlbum) { if (isAlbum) {
playlist.title = playlist.title.slice(8); playlist.title = playlist.title.slice(8);
} }
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' }); const safePlaylistTitle = filenamify(playlist.title, { replacement: " " });
const folder = getFolder(config.get('downloadFolder')); const folder = getFolder(config.get("downloadFolder"));
const playlistFolder = join(folder, safePlaylistTitle); const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) { if (existsSync(playlistFolder)) {
if (!config.get('skipExisting')) { if (!config.get("skipExisting")) {
sendError(new Error(`The folder ${playlistFolder} already exists`)); sendError(new Error(`The folder ${playlistFolder} already exists`));
return; return;
} }
@ -314,7 +357,7 @@ async function downloadPlaylist(givenUrl) {
if (is.dev()) { if (is.dev()) {
console.log( console.log(
`Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})` `Downloading playlist "${playlist.title}" - ${playlist.items.length} songs (${playlistId})`,
); );
} }
@ -328,7 +371,7 @@ async function downloadPlaylist(givenUrl) {
const increaseProgress = (itemPercentage) => { const increaseProgress = (itemPercentage) => {
const currentProgress = (counter - 1) / playlist.items.length; const currentProgress = (counter - 1) / playlist.items.length;
const newProgress = currentProgress + (progressStep * itemPercentage); const newProgress = currentProgress + progressStep * itemPercentage;
win.setProgressBar(newProgress); win.setProgressBar(newProgress);
}; };
@ -336,8 +379,16 @@ async function downloadPlaylist(givenUrl) {
for (const song of playlist.items) { for (const song of playlist.items) {
sendFeedback(`Downloading ${counter}/${playlist.items.length}...`); sendFeedback(`Downloading ${counter}/${playlist.items.length}...`);
const trackId = isAlbum ? counter : undefined; const trackId = isAlbum ? counter : undefined;
await downloadSong(song.url, playlistFolder, trackId, increaseProgress) await downloadSong(
.catch((e) => sendError(`Error downloading "${song.author.name} - ${song.title}":\n ${e}`)); song.url,
playlistFolder,
trackId,
increaseProgress,
).catch((e) =>
sendError(
`Error downloading "${song.author.name} - ${song.title}":\n ${e}`,
),
);
win.setProgressBar(counter / playlist.items.length); win.setProgressBar(counter / playlist.items.length);
setBadge(playlist.items.length - counter); setBadge(playlist.items.length - counter);
@ -365,7 +416,7 @@ async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
filePath, filePath,
...getFFmpegMetadataArgs(metadata), ...getFFmpegMetadataArgs(metadata),
...ffmpegArgs, ...ffmpegArgs,
filePath filePath,
); );
} catch (e) { } catch (e) {
sendError(e); sendError(e);
@ -385,25 +436,26 @@ function getFFmpegMetadataArgs(metadata) {
...(metadata.album ? ["-metadata", `album=${metadata.album}`] : []), ...(metadata.album ? ["-metadata", `album=${metadata.album}`] : []),
...(metadata.trackId ? ["-metadata", `track=${metadata.trackId}`] : []), ...(metadata.trackId ? ["-metadata", `track=${metadata.trackId}`] : []),
]; ];
}; }
// Playlist radio modifier needs to be cut from playlist ID // Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL'; const INVALID_PLAYLIST_MODIFIER = "RDAMPL";
const getPlaylistID = aURL => { const getPlaylistID = (aURL) => {
const result = aURL?.searchParams.get("list") || aURL?.searchParams.get("playlist"); const result =
aURL?.searchParams.get("list") || aURL?.searchParams.get("playlist");
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) { if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
return result.slice(6) return result.slice(6);
} }
return result; return result;
}; };
const getVideoId = url => { const getVideoId = (url) => {
if (typeof url === "string") { if (typeof url === "string") {
url = new URL(url); url = new URL(url);
} }
return url.searchParams.get("v"); return url.searchParams.get("v");
} };
const getMetadata = (info) => ({ const getMetadata = (info) => ({
id: info.basic_info.id, id: info.basic_info.id,

View File

@ -4,20 +4,18 @@ const { downloadPlaylist } = require("./back");
const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils"); const { defaultMenuDownloadLabel, getFolder, presets } = require("./utils");
const config = require("./config"); const config = require("./config");
let downloadLabel = defaultMenuDownloadLabel;
module.exports = () => { module.exports = () => {
return [ return [
{ {
label: downloadLabel, label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(), click: () => downloadPlaylist(),
}, },
{ {
label: "Choose download folder", label: "Choose download folder",
click: () => { click: () => {
let result = dialog.showOpenDialogSync({ const result = dialog.showOpenDialogSync({
properties: ["openDirectory", "createDirectory"], properties: ["openDirectory", "createDirectory"],
defaultPath: getFolder(config.get('downloadFolder')), defaultPath: getFolder(config.get("downloadFolder")),
}); });
if (result) { if (result) {
config.set("downloadFolder", result[0]); config.set("downloadFolder", result[0]);
@ -29,7 +27,7 @@ module.exports = () => {
submenu: Object.keys(presets).map((preset) => ({ submenu: Object.keys(presets).map((preset) => ({
label: preset, label: preset,
type: "radio", type: "radio",
checked: config.get('preset') === preset, checked: config.get("preset") === preset,
click: () => { click: () => {
config.set("preset", preset); config.set("preset", preset);
}, },
@ -38,10 +36,10 @@ module.exports = () => {
{ {
label: "Skip existing files", label: "Skip existing files",
type: "checkbox", type: "checkbox",
checked: config.get('skipExisting'), checked: config.get("skipExisting"),
click: (item) => { click: (item) => {
config.set("skipExisting", item.checked); config.set("skipExisting", item.checked);
} },
} },
]; ];
}; };