mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-15 12:21:47 +00:00
QOL: Move source code under the src directory. (#1318)
This commit is contained in:
634
src/plugins/downloader/back.ts
Normal file
634
src/plugins/downloader/back.ts
Normal file
@ -0,0 +1,634 @@
|
||||
import {
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { app, BrowserWindow, dialog, ipcMain, net } from 'electron';
|
||||
import {
|
||||
ClientType,
|
||||
Innertube,
|
||||
UniversalCache,
|
||||
Utils,
|
||||
YTNodes,
|
||||
} from 'youtubei.js';
|
||||
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,
|
||||
sendFeedback as sendFeedback_,
|
||||
setBadge,
|
||||
} from './utils';
|
||||
import config from './config';
|
||||
import { YoutubeFormatList, type Preset, DefaultPresetList } from './types';
|
||||
|
||||
import style from './style.css';
|
||||
|
||||
import { fetchFromGenius } from '../lyrics-genius/back';
|
||||
import { isEnabled } from '../../config/plugins';
|
||||
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
||||
import { injectCSS } from '../utils';
|
||||
import { cache } from '../../providers/decorators';
|
||||
|
||||
import type { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||
import type PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||
import type { Playlist } from 'youtubei.js/dist/src/parser/ytmusic';
|
||||
import type { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
||||
import type TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
||||
|
||||
import type { GetPlayerResponse } from '../../types/get-player-response';
|
||||
|
||||
type CustomSongInfo = SongInfo & { trackId?: string };
|
||||
|
||||
const ffmpeg = createFFmpeg({
|
||||
log: false,
|
||||
logger() {}, // Console.log,
|
||||
progress() {}, // Console.log,
|
||||
});
|
||||
const ffmpegMutex = new Mutex();
|
||||
|
||||
let yt: Innertube;
|
||||
let win: BrowserWindow;
|
||||
let playingUrl: string;
|
||||
|
||||
const sendError = (error: Error, source?: string) => {
|
||||
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${String(error.cause)}` : '';
|
||||
const message = `${error.toString()}${songNameMessage}${cause}`;
|
||||
|
||||
console.error(message, error, error?.stack);
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
buttons: ['OK'],
|
||||
title: 'Error in download!',
|
||||
message: 'Argh! Apologies, download failed…',
|
||||
detail: message,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCookieFromWindow = async (win: BrowserWindow) => {
|
||||
return (
|
||||
await win.webContents.session.cookies.get({
|
||||
url: 'https://music.youtube.com',
|
||||
})
|
||||
)
|
||||
.map((it) => it.name + '=' + it.value + ';')
|
||||
.join('');
|
||||
};
|
||||
|
||||
export default async (win_: BrowserWindow) => {
|
||||
win = win_;
|
||||
injectCSS(win.webContents, style);
|
||||
|
||||
yt = await Innertube.create({
|
||||
cache: new UniversalCache(false),
|
||||
cookie: await getCookieFromWindow(win),
|
||||
generate_session_locally: true,
|
||||
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? new URL(input)
|
||||
: input instanceof URL
|
||||
? input
|
||||
: new URL(input.url);
|
||||
|
||||
if (init?.body && !init.method) {
|
||||
init.method = 'POST';
|
||||
}
|
||||
|
||||
const request = new Request(
|
||||
url,
|
||||
input instanceof Request ? input : undefined,
|
||||
);
|
||||
|
||||
return net.fetch(request, init);
|
||||
}) as typeof fetch,
|
||||
});
|
||||
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
||||
ipcMain.on('video-src-changed', (_, data: GetPlayerResponse) => {
|
||||
playingUrl = data.microformat.microformatDataRenderer.urlCanonical;
|
||||
});
|
||||
ipcMain.on('download-playlist-request', async (_event, url: string) =>
|
||||
downloadPlaylist(url),
|
||||
);
|
||||
};
|
||||
|
||||
export async function downloadSong(
|
||||
url: string,
|
||||
playlistFolder: string | undefined = undefined,
|
||||
trackId: string | undefined = undefined,
|
||||
increasePlaylistProgress: (value: number) => void = () => {},
|
||||
) {
|
||||
let resolvedName;
|
||||
try {
|
||||
await downloadSongUnsafe(
|
||||
false,
|
||||
url,
|
||||
(name: string) => (resolvedName = name),
|
||||
playlistFolder,
|
||||
trackId,
|
||||
increasePlaylistProgress,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error, resolvedName || url);
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadSongFromId(
|
||||
id: string,
|
||||
playlistFolder: string | undefined = undefined,
|
||||
trackId: string | undefined = undefined,
|
||||
increasePlaylistProgress: (value: number) => void = () => {},
|
||||
) {
|
||||
let resolvedName;
|
||||
try {
|
||||
await downloadSongUnsafe(
|
||||
true,
|
||||
id,
|
||||
(name: string) => (resolvedName = name),
|
||||
playlistFolder,
|
||||
trackId,
|
||||
increasePlaylistProgress,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error, resolvedName || id);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSongUnsafe(
|
||||
isId: boolean,
|
||||
idOrUrl: string,
|
||||
setName: (name: string) => void,
|
||||
playlistFolder: string | undefined = undefined,
|
||||
trackId: string | undefined = undefined,
|
||||
increasePlaylistProgress: (value: number) => void = () => {},
|
||||
) {
|
||||
const sendFeedback = (message: unknown, progress?: number) => {
|
||||
if (!playlistFolder) {
|
||||
sendFeedback_(win, message);
|
||||
if (progress && !isNaN(progress)) {
|
||||
win.setProgressBar(progress);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
sendFeedback('Downloading...', 2);
|
||||
|
||||
let id: string | null;
|
||||
if (isId) {
|
||||
id = idOrUrl;
|
||||
} else {
|
||||
id = getVideoId(idOrUrl);
|
||||
if (typeof id !== 'string') throw new Error('Video not found');
|
||||
}
|
||||
|
||||
let info: TrackInfo | VideoInfo = 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') {
|
||||
const errorScreen =
|
||||
playabilityStatus.error_screen as PlayerErrorMessage | null;
|
||||
throw new Error(
|
||||
`[${playabilityStatus.status}] ${errorScreen?.reason.text}: ${errorScreen?.subreason.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
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)'];
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
if (!is.macOS()) {
|
||||
filename = filename.normalize('NFC');
|
||||
}
|
||||
const filePath = join(dir, filename);
|
||||
|
||||
if (config.get('skipExisting') && existsSync(filePath)) {
|
||||
sendFeedback(null, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = await info.download(downloadOptions);
|
||||
|
||||
console.info(
|
||||
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`,
|
||||
);
|
||||
|
||||
const iterableStream = Utils.streamToIterable(stream);
|
||||
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir);
|
||||
}
|
||||
|
||||
const fileBuffer = await iterableStreamToTargetFile(
|
||||
iterableStream,
|
||||
targetFileExtension,
|
||||
metadata,
|
||||
presetSetting?.ffmpegArgs ?? [],
|
||||
format.content_length ?? 0,
|
||||
sendFeedback,
|
||||
increasePlaylistProgress,
|
||||
);
|
||||
|
||||
if (fileBuffer) {
|
||||
if (targetFileExtension !== 'mp3') {
|
||||
createWriteStream(filePath).write(fileBuffer);
|
||||
} else {
|
||||
const buffer = await writeID3(
|
||||
Buffer.from(fileBuffer),
|
||||
metadata,
|
||||
sendFeedback,
|
||||
);
|
||||
if (buffer) {
|
||||
writeFileSync(filePath, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendFeedback(null, -1);
|
||||
console.info(`Done: "${filePath}"`);
|
||||
}
|
||||
|
||||
async function iterableStreamToTargetFile(
|
||||
stream: AsyncGenerator<Uint8Array, void>,
|
||||
extension: string,
|
||||
metadata: CustomSongInfo,
|
||||
presetFfmpegArgs: string[],
|
||||
contentLength: number,
|
||||
sendFeedback: (str: string, value?: number) => void,
|
||||
increasePlaylistProgress: (value: number) => void = () => {},
|
||||
) {
|
||||
const chunks = [];
|
||||
let downloaded = 0;
|
||||
for await (const chunk of stream) {
|
||||
downloaded += chunk.length;
|
||||
chunks.push(chunk);
|
||||
const ratio = downloaded / contentLength;
|
||||
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);
|
||||
});
|
||||
|
||||
const safeVideoNameWithExtension = `${safeVideoName}.${extension}`;
|
||||
try {
|
||||
await ffmpeg.run(
|
||||
'-i',
|
||||
safeVideoName,
|
||||
...presetFfmpegArgs,
|
||||
...getFFmpegMetadataArgs(metadata),
|
||||
safeVideoNameWithExtension,
|
||||
);
|
||||
} finally {
|
||||
ffmpeg.FS('unlink', safeVideoName);
|
||||
}
|
||||
|
||||
sendFeedback('Saving…');
|
||||
|
||||
try {
|
||||
return ffmpeg.FS('readFile', safeVideoNameWithExtension);
|
||||
} finally {
|
||||
ffmpeg.FS('unlink', safeVideoNameWithExtension);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error, safeVideoName);
|
||||
} finally {
|
||||
releaseFFmpegMutex();
|
||||
}
|
||||
}
|
||||
|
||||
const getCoverBuffer = cache(async (url: string) => {
|
||||
const nativeImage = cropMaxWidth(await getImage(url));
|
||||
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
|
||||
});
|
||||
|
||||
async function writeID3(
|
||||
buffer: Buffer,
|
||||
metadata: CustomSongInfo,
|
||||
sendFeedback: (str: string, value?: number) => void,
|
||||
) {
|
||||
try {
|
||||
sendFeedback('Writing ID3 tags...');
|
||||
const tags: NodeID3.Tags = {};
|
||||
|
||||
// Create the metadata tags
|
||||
tags.title = metadata.title;
|
||||
tags.artist = metadata.artist;
|
||||
|
||||
if (metadata.album) {
|
||||
tags.album = metadata.album;
|
||||
}
|
||||
|
||||
const coverBuffer = await getCoverBuffer(metadata.imageSrc ?? '');
|
||||
if (coverBuffer) {
|
||||
tags.image = {
|
||||
mime: 'image/png',
|
||||
type: {
|
||||
id: TagConstants.AttachedPicture.PictureType.FRONT_COVER,
|
||||
},
|
||||
description: 'thumbnail',
|
||||
imageBuffer: coverBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
if (isEnabled('lyrics-genius')) {
|
||||
const lyrics = await fetchFromGenius(metadata);
|
||||
if (lyrics) {
|
||||
tags.unsynchronisedLyrics = {
|
||||
language: '',
|
||||
text: lyrics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.trackId) {
|
||||
tags.trackNumber = metadata.trackId;
|
||||
}
|
||||
|
||||
return NodeID3.write(tags, buffer);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error, `${metadata.artist} - ${metadata.title}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
try {
|
||||
givenUrl = new URL(givenUrl ?? '');
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
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?: unknown) => sendFeedback_(win, message);
|
||||
|
||||
console.log(`trying to get playlist ID: '${playlistId}'`);
|
||||
sendFeedback('Getting playlist info…');
|
||||
let playlist: Playlist;
|
||||
try {
|
||||
playlist = await yt.music.getPlaylist(playlistId);
|
||||
} catch (error: unknown) {
|
||||
sendError(
|
||||
Error(
|
||||
`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(
|
||||
error,
|
||||
)}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!playlist || !playlist.items || playlist.items.length === 0) {
|
||||
sendError(new Error('Playlist is empty'));
|
||||
}
|
||||
|
||||
const items = playlist.items!.as(YTNodes.MusicResponsiveListItem);
|
||||
if (items.length === 1) {
|
||||
sendFeedback('Playlist has only one item, downloading it directly');
|
||||
await downloadSongFromId(items.at(0)!.id!);
|
||||
return;
|
||||
}
|
||||
|
||||
const normalPlaylistTitle = playlist.header?.title?.text;
|
||||
const playlistTitle =
|
||||
normalPlaylistTitle ??
|
||||
playlist.page.contents_memo
|
||||
?.get('MusicResponsiveListItemFlexColumn')
|
||||
?.at(2)
|
||||
?.as(YTNodes.MusicResponsiveListItemFlexColumn)?.title?.text ??
|
||||
'NO_TITLE';
|
||||
const isAlbum = !normalPlaylistTitle;
|
||||
|
||||
let safePlaylistTitle = filenamify(playlistTitle, { replacement: ' ' });
|
||||
if (!is.macOS()) {
|
||||
safePlaylistTitle = safePlaylistTitle.normalize('NFC');
|
||||
}
|
||||
|
||||
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 "${playlistTitle}"`,
|
||||
detail: `(${items.length} songs)`,
|
||||
});
|
||||
|
||||
if (is.dev()) {
|
||||
console.log(
|
||||
`Downloading playlist "${playlistTitle}" - ${items.length} songs (${playlistId})`,
|
||||
);
|
||||
}
|
||||
|
||||
win.setProgressBar(2); // Starts with indefinite bar
|
||||
|
||||
setBadge(items.length);
|
||||
|
||||
let counter = 1;
|
||||
|
||||
const progressStep = 1 / items.length;
|
||||
|
||||
const increaseProgress = (itemPercentage: number) => {
|
||||
const currentProgress = (counter - 1) / (items.length ?? 1);
|
||||
const newProgress = currentProgress + progressStep * itemPercentage;
|
||||
win.setProgressBar(newProgress);
|
||||
};
|
||||
|
||||
try {
|
||||
for (const song of items) {
|
||||
sendFeedback(`Downloading ${counter}/${items.length}...`);
|
||||
const trackId = isAlbum ? counter : undefined;
|
||||
await downloadSongFromId(
|
||||
song.id!,
|
||||
playlistFolder,
|
||||
trackId?.toString(),
|
||||
increaseProgress,
|
||||
).catch((error) =>
|
||||
sendError(
|
||||
new Error(
|
||||
`Error downloading "${
|
||||
song.author!.name
|
||||
} - ${song.title!}":\n ${error}`,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
win.setProgressBar(counter / items.length);
|
||||
setBadge(items.length - counter);
|
||||
counter++;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error);
|
||||
} finally {
|
||||
win.setProgressBar(-1); // Close progress bar
|
||||
setBadge(0); // Close badge counter
|
||||
sendFeedback(); // Clear feedback
|
||||
}
|
||||
}
|
||||
|
||||
function getFFmpegMetadataArgs(metadata: CustomSongInfo) {
|
||||
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: URL) => {
|
||||
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: URL | string): string | null => {
|
||||
return new URL(url).searchParams.get('v');
|
||||
};
|
||||
|
||||
const getMetadata = (info: TrackInfo): CustomSongInfo => ({
|
||||
videoId: info.basic_info.id!,
|
||||
title: cleanupName(info.basic_info.title!),
|
||||
artist: cleanupName(info.basic_info.author!),
|
||||
album: info.player_overlays?.browser_media_session?.as(
|
||||
YTNodes.BrowserMediaSession,
|
||||
).album?.text,
|
||||
imageSrc: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))
|
||||
?.url,
|
||||
views: info.basic_info.view_count!,
|
||||
songDuration: info.basic_info.duration!,
|
||||
});
|
||||
|
||||
// This is used to bypass age restrictions
|
||||
const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
|
||||
const innertube = await Innertube.create({
|
||||
client_type: ClientType.TV_EMBEDDED,
|
||||
generate_session_locally: true,
|
||||
retrieve_player: true,
|
||||
});
|
||||
// GetInfo 404s with the bypass, so we use getBasicInfo instead
|
||||
// that's fine as we only need the streaming data
|
||||
return await innertube.getBasicInfo(id, 'TV_EMBEDDED');
|
||||
};
|
||||
4
src/plugins/downloader/config.ts
Normal file
4
src/plugins/downloader/config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PluginConfig } from '../../config/dynamic';
|
||||
|
||||
const config = new PluginConfig('downloader');
|
||||
export default config;
|
||||
83
src/plugins/downloader/front.ts
Normal file
83
src/plugins/downloader/front.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import { ipcRenderer } from 'electron';
|
||||
|
||||
import downloadHTML from './templates/download.html';
|
||||
|
||||
import defaultConfig from '../../config/defaults';
|
||||
import { getSongMenu } from '../../providers/dom-elements';
|
||||
import { ElementFromHtml } from '../utils';
|
||||
import { getSongInfo } from '../../providers/song-info-front';
|
||||
|
||||
let menu: Element | null = null;
|
||||
let progress: Element | null = null;
|
||||
const downloadButton = ElementFromHtml(downloadHTML);
|
||||
|
||||
let doneFirstLoad = false;
|
||||
|
||||
const menuObserver = new MutationObserver(() => {
|
||||
if (!menu) {
|
||||
menu = getSongMenu();
|
||||
if (!menu) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (menu.contains(downloadButton)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const menuUrl = document.querySelector<HTMLAnchorElement>('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
|
||||
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
menu.prepend(downloadButton);
|
||||
progress = document.querySelector('#ytmcustom-download');
|
||||
|
||||
if (doneFirstLoad) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(() => doneFirstLoad ||= true, 500);
|
||||
});
|
||||
|
||||
// TODO: re-enable once contextIsolation is set to true
|
||||
// contextBridge.exposeInMainWorld("downloader", {
|
||||
// download: () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any,@typescript-eslint/no-unsafe-member-access
|
||||
(global as any).download = () => {
|
||||
let videoUrl = getSongMenu()
|
||||
// Selector of first button which is always "Start Radio"
|
||||
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="-1"] #navigation-endpoint')
|
||||
?.getAttribute('href');
|
||||
if (videoUrl) {
|
||||
if (videoUrl.startsWith('watch?')) {
|
||||
videoUrl = defaultConfig.url + '/' + videoUrl;
|
||||
}
|
||||
|
||||
if (videoUrl.includes('?playlist=')) {
|
||||
ipcRenderer.send('download-playlist-request', videoUrl);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
videoUrl = getSongInfo().url || window.location.href;
|
||||
}
|
||||
|
||||
ipcRenderer.send('download-song', videoUrl);
|
||||
};
|
||||
|
||||
export default () => {
|
||||
document.addEventListener('apiLoaded', () => {
|
||||
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}, { once: true, passive: true });
|
||||
|
||||
ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
|
||||
if (progress) {
|
||||
progress.innerHTML = feedback || 'Download';
|
||||
} else {
|
||||
console.warn('Cannot update progress');
|
||||
}
|
||||
});
|
||||
};
|
||||
46
src/plugins/downloader/menu.ts
Normal file
46
src/plugins/downloader/menu.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import { dialog } from 'electron';
|
||||
|
||||
import { downloadPlaylist } from './back';
|
||||
import { defaultMenuDownloadLabel, getFolder } from './utils';
|
||||
import { DefaultPresetList } from './types';
|
||||
import config from './config';
|
||||
|
||||
import { MenuTemplate } from '../../menu';
|
||||
|
||||
export default (): MenuTemplate => [
|
||||
{
|
||||
label: defaultMenuDownloadLabel,
|
||||
click: () => downloadPlaylist(),
|
||||
},
|
||||
{
|
||||
label: 'Choose download folder',
|
||||
click() {
|
||||
const result = dialog.showOpenDialogSync({
|
||||
properties: ['openDirectory', 'createDirectory'],
|
||||
defaultPath: getFolder(config.get('downloadFolder') ?? ''),
|
||||
});
|
||||
if (result) {
|
||||
config.set('downloadFolder', result[0]);
|
||||
} // Else = user pressed cancel
|
||||
},
|
||||
},
|
||||
{
|
||||
label: 'Presets',
|
||||
submenu: Object.keys(DefaultPresetList).map((preset) => ({
|
||||
label: preset,
|
||||
type: 'radio',
|
||||
checked: config.get('selectedPreset') === preset,
|
||||
click() {
|
||||
config.set('selectedPreset', preset);
|
||||
},
|
||||
})),
|
||||
},
|
||||
{
|
||||
label: 'Skip existing files',
|
||||
type: 'checkbox',
|
||||
checked: config.get('skipExisting'),
|
||||
click(item) {
|
||||
config.set('skipExisting', item.checked);
|
||||
},
|
||||
},
|
||||
];
|
||||
21
src/plugins/downloader/style.css
Normal file
21
src/plugins/downloader/style.css
Normal file
@ -0,0 +1,21 @@
|
||||
.menu-item {
|
||||
display: var(--ytmusic-menu-item_-_display);
|
||||
height: var(--ytmusic-menu-item_-_height);
|
||||
align-items: var(--ytmusic-menu-item_-_align-items);
|
||||
padding: var(--ytmusic-menu-item_-_padding);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu-item > .yt-simple-endpoint:hover {
|
||||
background-color: var(--ytmusic-menu-item-hover-background-color);
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
flex: var(--ytmusic-menu-item-icon_-_flex);
|
||||
margin: var(--ytmusic-menu-item-icon_-_margin);
|
||||
fill: var(--ytmusic-menu-item-icon_-_fill);
|
||||
stroke: var(--iron-icon-stroke-color, none);
|
||||
width: var(--iron-icon-width, 24px);
|
||||
height: var(--iron-icon-height, 24px);
|
||||
animation: var(--iron-icon_-_animation);
|
||||
}
|
||||
45
src/plugins/downloader/templates/download.html
Normal file
45
src/plugins/downloader/templates/download.html
Normal file
@ -0,0 +1,45 @@
|
||||
<div
|
||||
aria-disabled="false"
|
||||
aria-selected="false"
|
||||
class="style-scope menu-item ytmusic-menu-popup-renderer"
|
||||
onclick="download()"
|
||||
role="option"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="yt-simple-endpoint style-scope ytmusic-menu-navigation-item-renderer"
|
||||
id="navigation-endpoint"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="icon menu-icon style-scope ytmusic-menu-navigation-item-renderer"
|
||||
>
|
||||
<svg
|
||||
class="style-scope yt-icon"
|
||||
focusable="false"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
style="pointer-events: none; display: block; width: 100%; height: 100%"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<g class="style-scope yt-icon">
|
||||
<path
|
||||
class="style-scope yt-icon"
|
||||
d="M25.462,19.105v6.848H4.515v-6.848H0.489v8.861c0,1.111,0.9,2.012,2.016,2.012h24.967c1.115,0,2.016-0.9,2.016-2.012v-8.861H25.462z"
|
||||
fill="#aaaaaa"
|
||||
/>
|
||||
<path
|
||||
class="style-scope yt-icon"
|
||||
d="M14.62,18.426l-5.764-6.965c0,0-0.877-0.828,0.074-0.828s3.248,0,3.248,0s0-0.557,0-1.416c0-2.449,0-6.906,0-8.723c0,0-0.129-0.494,0.615-0.494c0.75,0,4.035,0,4.572,0c0.536,0,0.524,0.416,0.524,0.416c0,1.762,0,6.373,0,8.742c0,0.768,0,1.266,0,1.266s1.842,0,2.998,0c1.154,0,0.285,0.867,0.285,0.867s-4.904,6.51-5.588,7.193C15.092,18.979,14.62,18.426,14.62,18.426z"
|
||||
fill="#aaaaaa"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
class="text style-scope ytmusic-menu-navigation-item-renderer"
|
||||
id="ytmcustom-download"
|
||||
>
|
||||
Download
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
116
src/plugins/downloader/types.ts
Normal file
116
src/plugins/downloader/types.ts
Normal 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: '' },
|
||||
];
|
||||
30
src/plugins/downloader/utils.ts
Normal file
30
src/plugins/downloader/utils.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { app, BrowserWindow } from 'electron';
|
||||
import is from 'electron-is';
|
||||
|
||||
export const getFolder = (customFolder: string) => customFolder || app.getPath('downloads');
|
||||
export const defaultMenuDownloadLabel = 'Download playlist';
|
||||
|
||||
export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
|
||||
win.webContents.send('downloader-feedback', message);
|
||||
};
|
||||
|
||||
export const cropMaxWidth = (image: Electron.NativeImage) => {
|
||||
const imageSize = image.getSize();
|
||||
// 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,
|
||||
y: 0,
|
||||
width: 720,
|
||||
height: 720,
|
||||
});
|
||||
}
|
||||
|
||||
return image;
|
||||
};
|
||||
|
||||
export const setBadge = (n: number) => {
|
||||
if (is.linux() || is.macOS()) {
|
||||
app.setBadgeCount(n);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user