add download feedback and progress

This commit is contained in:
Araxeus
2023-03-04 11:57:56 +02:00
parent 54d3f925e6
commit 099e5d8491
7 changed files with 199 additions and 254 deletions

View File

@ -1,38 +1,67 @@
const { existsSync, mkdirSync, createWriteStream, writeFileSync } = require('fs');
const { ipcMain, app } = require("electron");
const { join } = require("path");
const { Innertube, UniversalCache, Utils } = require('youtubei.js');
const filenamify = require("filenamify");
const ID3Writer = require("browser-id3-writer");
const { fetchFromGenius } = require("../lyrics-genius/back");
const { isEnabled } = require("../../config/plugins");
const { getImage } = require("../../providers/song-info");
const { cropMaxWidth } = require("./utils");
const { sendError } = require("./back");
const { presets } = require('./utils');
const { presets, cropMaxWidth, getFolder, setBadge, sendFeedback: sendFeedback_ } = require('./utils');
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 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();
/** @type {Innertube} */
let yt;
let options;
let win;
let playingUrl = undefined;
module.exports = async (options_) => {
module.exports = async (win_, options_) => {
options = options_;
win = win_;
yt = await Innertube.create({ cache: new UniversalCache(false), generate_session_locally: true });
ipcMain.handle("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, win, options));
};
async function downloadSong(url, playlistFolder = undefined, trackId = undefined) {
async function downloadSong(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 metadata = await getMetadata(url);
metadata.trackId = trackId;
const stream = await yt.download(metadata.id, {
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 = metadata.info.chooseFormat(download_options);
const stream = await metadata.info.download(download_options);
console.info(`Downloading ${metadata.artist} - ${metadata.title} {${metadata.id}}...`);
@ -54,30 +83,32 @@ async function downloadSong(url, playlistFolder = undefined, trackId = undefined
}
if (!presets[options.preset]) {
const fileBuffer = await toMP3(iterableStream, metadata);
console.info('writing id3 tags...'); // DELETE
writeFileSync(filePath, await writeID3(fileBuffer, metadata));
console.info('done writing id3 tags!'); // DELETE
const fileBuffer = await toMP3(iterableStream, metadata, format.content_length, sendFeedback, increasePlaylistProgress);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
} else {
const file = createWriteStream(filePath);
//stream.pipeTo(file);
let downloaded = 0;
let 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[options.preset]?.ffmpegArgs);
sendFeedback(null, -1);
}
sendFeedback(null, -1);
console.info(`${filePath} - Done!`, '\n');
}
module.exports.downloadSong = downloadSong;
function getIdFromUrl(url) {
const match = url.match(/v=([^&]+)/);
return match ? match[1] : null;
}
async function getMetadata(url) {
const id = getIdFromUrl(url);
const id = url.match(/v=([^&]+)/)?.[1];
const info = await yt.music.getInfo(id);
return {
@ -86,11 +117,14 @@ async function getMetadata(url) {
artist: info.basic_info.author,
album: info.player_overlays?.browser_media_session?.album?.text,
image: info.basic_info.thumbnail[0].url,
info
};
}
async function writeID3(buffer, metadata) {
async function writeID3(buffer, metadata, sendFeedback) {
try {
sendFeedback("Writing ID3 tags...");
const nativeImage = cropMaxWidth(await getImage(metadata.image));
const coverBuffer = nativeImage && !nativeImage.isEmpty() ?
nativeImage.toPNG() : null;
@ -130,36 +164,33 @@ async function writeID3(buffer, metadata) {
}
}
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();
async function toMP3(stream, metadata, extension = "mp3") {
async function toMP3(stream, metadata, content_length, sendFeedback, increasePlaylistProgress = () => { }, extension = "mp3") {
const chunks = [];
let downloaded = 0;
let 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);
increasePlaylistProgress(ratio);
}
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()) {
// sendFeedback("Loading…", 2); // indefinite progress bar after download
await ffmpeg.load();
}
// sendFeedback("Preparing file…");
sendFeedback("Preparing file…");
ffmpeg.FS("writeFile", safeVideoName, buffer);
// sendFeedback("Converting…");
sendFeedback("Converting…");
await ffmpeg.run(
"-i",
@ -168,9 +199,9 @@ async function toMP3(stream, metadata, extension = "mp3") {
safeVideoName + "." + extension
);
// sendFeedback("Saving…");
sendFeedback("Saving…");
return ffmpeg.FS("readFile", safeVideoName + "." + extension);
return ffmpeg.FS("readFile", safeVideoName + "." + extension);
} catch (e) {
sendError(e);
} finally {
@ -212,3 +243,104 @@ function getFFmpegMetadataArgs(metadata) {
...(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(6)
}
return result;
};
async function downloadPlaylist(givenUrl) {
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(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: options.playlistMaxItems || Infinity,
});
} catch (e) {
sendError(e);
return;
}
let isAlbum = playlist.title.startsWith('Album - ');
if (isAlbum) {
playlist.title = playlist.title.slice(8);
}
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
const folder = getFolder(options.downloadFolder);
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
sendError(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
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(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
}
}

View File

@ -4,12 +4,16 @@ const { dialog } = require("electron");
const registerCallback = require("../../providers/song-info");
const { injectCSS, listenAction } = require("../utils");
const { setBadge, sendFeedback } = require("./utils");
const { ACTIONS, CHANNEL } = require("./actions.js");
let win = {};
const sendError = (error) => {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge
sendFeedback(); // reset feedback
console.error(error);
dialog.showMessageBox({
@ -28,7 +32,7 @@ function handle(win_, options) {
win = win_;
injectCSS(win.webContents, join(__dirname, "style.css"));
require("./back-downloader")(options);
require("./back-downloader")(win, options);
registerCallback((info) => {
nowPlayingMetadata = info;

View File

@ -40,7 +40,7 @@ const baseUrl = defaultConfig.url;
// contextBridge.exposeInMainWorld("downloader", {
// download: () => {
global.download = () => {
triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar
//triggerAction(CHANNEL, ACTIONS.PROGRESS, 2); // starts with indefinite progress bar
let metadata;
let videoUrl = getSongMenu()
// selector of first button which is always "Start Radio"
@ -60,7 +60,7 @@ global.download = () => {
videoUrl = metadata.url || window.location.href;
}
ipcRenderer.invoke('download-song', videoUrl).finally(() => triggerAction(CHANNEL, ACTIONS.PROGRESS, -1));
ipcRenderer.invoke('download-song', videoUrl)//.finally(() => triggerAction(CHANNEL, ACTIONS.PROGRESS, -1));
return;
};
@ -73,6 +73,14 @@ function observeMenu(options) {
subtree: true,
});
}, { once: true, passive: true })
ipcRenderer.on('downloader-feedback', (_, feedback)=> {
if (!progress) {
console.warn("Cannot update progress");
} else {
progress.innerHTML = feedback || "Download";
}
});
}
module.exports = observeMenu;

View File

@ -1,41 +1,12 @@
const { existsSync, mkdirSync } = require("fs");
const { join } = require("path");
const { dialog, ipcMain } = require("electron");
const is = require("electron-is");
const ytpl = require("ytpl");
const chokidar = require('chokidar');
const filenamify = require('filenamify');
const { dialog } = require("electron");
const { setMenuOptions } = require("../../config/plugins");
const { sendError } = require("./back");
const { downloadSong } = require("./back-downloader");
const { defaultMenuDownloadLabel, getFolder, presets, setBadge } = require("./utils");
const { downloadPlaylist } = require("./back-downloader");
const { defaultMenuDownloadLabel, getFolder, presets } = 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;
}
return [
{
label: downloadLabel,
@ -68,95 +39,3 @@ module.exports = (win, options) => {
},
];
};
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(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(e);
return;
}
let isAlbum = playlist.title.startsWith('Album - ');
if (isAlbum) {
playlist.title = playlist.title.slice(8);
}
const safePlaylistTitle = filenamify(playlist.title, { replacement: ' ' });
const folder = getFolder(options.downloadFolder);
const playlistFolder = join(folder, safePlaylistTitle);
if (existsSync(playlistFolder)) {
sendError(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);
const closeDirWatcher = () => {
if (dirWatcher) {
win.setProgressBar(-1); // close progress bar
setBadge(0); // close badge counter
dirWatcher.close().then(() => (dirWatcher = null));
}
};
dirWatcher.on('add', () => {
downloadCount += 1;
if (downloadCount >= playlist.items.length) {
closeDirWatcher();
} else {
win.setProgressBar(downloadCount / playlist.items.length);
setBadge(playlist.items.length - downloadCount);
}
});
let counter = 1;
try {
for (const song of playlist.items) {
const trackId = isAlbum ? counter++ : undefined;
await downloadSong(song.url, playlistFolder, trackId).catch((e) => sendError(e));
}
} catch (e) {
sendError(e);
} finally {
closeDirWatcher();
}
}

View File

@ -4,6 +4,10 @@ const is = require('electron-is');
module.exports.getFolder = customFolder => customFolder || app.getPath("downloads");
module.exports.defaultMenuDownloadLabel = "Download playlist";
module.exports.sendFeedback = (win, message) => {
win.webContents.send("downloader-feedback", message);
};
const orderedQualityList = ["maxresdefault", "hqdefault", "mqdefault", "sdddefault"];
module.exports.urlToJPG = (imgUrl, videoId) => {
if (!imgUrl || imgUrl.includes(".jpg")) return imgUrl;