Files
youtube-music/plugins/downloader/back.js
2023-08-29 17:22:38 +09:00

545 lines
14 KiB
JavaScript

const {
existsSync,
mkdirSync,
createWriteStream,
writeFileSync,
} = require('node:fs');
const { join } = require('node:path');
const { randomBytes } = require('node:crypto');
const { ipcMain, app, dialog } = require('electron');
const is = require('electron-is');
const { Innertube, UniversalCache, Utils, ClientType } = 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 { Mutex } = require('async-mutex');
const ffmpeg = require('@ffmpeg/ffmpeg').createFFmpeg({
log: false,
logger() {
}, // Console.log,
progress() {
}, // Console.log,
});
const {
presets,
cropMaxWidth,
getFolder,
setBadge,
sendFeedback: sendFeedback_,
} = require('./utils');
const { fetchFromGenius } = require('../lyrics-genius/back');
const { isEnabled } = require('../../config/plugins');
const { getImage, cleanupName } = require('../../providers/song-info');
const { injectCSS } = require('../utils');
const { cache } = require('../../providers/decorators');
const ffmpegMutex = new Mutex();
const config = require('./config');
/** @type {Innertube} */
let yt;
let win;
let playingUrl;
const sendError = (error, source) => {
win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge
sendFeedback_(win); // Reset feedback
const songNameMessage = source ? `\nin ${source}` : '';
const cause = error.cause ? `\n\n${error.cause.toString()}` : '';
const message = `${error.toString()}${songNameMessage}${cause}`;
console.error(message);
dialog.showMessageBox({
type: 'info',
buttons: ['OK'],
title: 'Error in download!',
message: 'Argh! Apologies, download failed…',
detail: message,
});
};
module.exports = async (win_) => {
win = win_;
injectCSS(win.webContents, join(__dirname, 'style.css'));
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),
);
};
module.exports.downloadSong = downloadSong;
module.exports.downloadPlaylist = downloadPlaylist;
async function downloadSong(
url,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {
},
) {
let resolvedName;
try {
await downloadSongUnsafe(
url,
(name) => resolvedName = name,
playlistFolder,
trackId,
increasePlaylistProgress,
);
} catch (error) {
sendError(error, resolvedName || url);
}
}
async function downloadSongUnsafe(
url,
setName,
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);
let info = await yt.music.getInfo(id);
if (!info) {
throw new Error('Video not found');
}
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
}`;
setName(name);
let playabilityStatus = info.playability_status;
let bypassedResult = null;
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
// Try to bypass the age restriction
bypassedResult = await getAndroidTvInfo(id);
playabilityStatus = bypassedResult.playability_status;
if (playabilityStatus.status === 'LOGIN_REQUIRED') {
throw new Error(
`[${playabilityStatus.status}] ${playabilityStatus.reason}`,
);
}
info = bypassedResult;
}
if (playabilityStatus.status === 'UNPLAYABLE') {
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/PlayerErrorMessage').default} PlayerErrorMessage
* @type {PlayerErrorMessage}
*/
const errorScreen = playabilityStatus.error_screen;
throw new Error(
`[${playabilityStatus.status}] ${errorScreen.reason.text}: ${errorScreen.subreason.text}`,
);
}
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 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);
} else {
const fileBuffer = await iterableStreamToMP3(
iterableStream,
metadata,
format.content_length,
sendFeedback,
increasePlaylistProgress,
);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
}
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 (error) {
sendError(error, safeVideoName);
} finally {
releaseFFmpegMutex();
}
}
const getCoverBuffer = cache(async (url) => {
const nativeImage = cropMaxWidth(await getImage(url));
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
});
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,
});
}
}
if (metadata.trackId) {
writer.setFrame('TRCK', metadata.trackId);
}
writer.addTag();
return Buffer.from(writer.arrayBuffer);
} catch (error) {
sendError(error, `${metadata.artist} - ${metadata.title}`);
}
}
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') || Number.POSITIVE_INFINITY,
});
} catch (error) {
sendError(
`Error getting playlist info: make sure it isn\'t a private or "Mixed for you" playlist\n\n${error}`,
);
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((error) =>
sendError(
`Error downloading "${song.author.name} - ${song.title}":\n ${error}`,
),
);
win.setProgressBar(counter / playlist.items.length);
setBadge(playlist.items.length - counter);
counter++;
}
} catch (error) {
sendError(error);
} 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 (error) {
sendError(error);
} 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: cleanupName(info.basic_info.title),
artist: cleanupName(info.basic_info.author),
album: info.player_overlays?.browser_media_session?.album?.text,
image: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))?.url,
});
// This is used to bypass age restrictions
const getAndroidTvInfo = async (id) => {
const innertube = await Innertube.create({
clientType: ClientType.TV_EMBEDDED,
generate_session_locally: true,
retrieve_player: true,
});
const info = await innertube.getBasicInfo(id, 'TV_EMBEDDED');
// GetInfo 404s with the bypass, so we use getBasicInfo instead
// that's fine as we only need the streaming data
return info;
};