QOL: Move source code under the src directory. (#1318)

This commit is contained in:
Angelos Bouklis
2023-10-15 15:52:48 +03:00
committed by GitHub
parent 30c8dcf730
commit 7625a3aa52
159 changed files with 102 additions and 71 deletions

View 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');
};

View File

@ -0,0 +1,4 @@
import { PluginConfig } from '../../config/dynamic';
const config = new PluginConfig('downloader');
export default config;

View 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');
}
});
};

View 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);
},
},
];

View 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);
}

View 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>

View 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: '' },
];

View 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);
}
};