feat: migration to TypeScript part 3

Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
JellyBrick
2023-09-03 21:02:57 +09:00
parent 03c1ab0e98
commit 278618bc83
25 changed files with 674 additions and 380 deletions

View File

@ -162,7 +162,7 @@ export default (
largeImageKey: songInfo.imageSrc ?? '',
largeImageText: songInfo.album ?? '',
buttons: options.listenAlong ? [
{ label: 'Listen Along', url: songInfo.url },
{ label: 'Listen Along', url: songInfo.url ?? '' },
] : undefined,
};
@ -176,7 +176,7 @@ export default (
}
} else if (!options.hideDurationLeft) {
// Add the start and end time of the song
const songStartTime = Date.now() - (songInfo.elapsedSeconds * 1000);
const songStartTime = Date.now() - ((songInfo.elapsedSeconds ?? 0) * 1000);
activityInfo.startTimestamp = songStartTime;
activityInfo.endTimestamp
= songStartTime + (songInfo.songDuration * 1000);

View File

@ -1,58 +1,64 @@
const {
import {
existsSync,
mkdirSync,
createWriteStream,
writeFileSync,
} = require('node:fs');
const { join } = require('node:path');
} from 'node:fs';
import { join } from 'node:path';
import { randomBytes } from 'node:crypto';
const { randomBytes } = require('node:crypto');
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
import { ClientType, Innertube, UniversalCache, Utils } from 'youtubei.js';
import is from 'electron-is';
import ytpl from 'ytpl';
// REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
import filenamify from 'filenamify';
import { Mutex } from 'async-mutex';
import { createFFmpeg } from '@ffmpeg/ffmpeg';
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({
import NodeID3, { TagConstants } from 'node-id3';
import PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
import { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
import TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
import { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
import { cropMaxWidth, getFolder, presets, sendFeedback as sendFeedback_, setBadge } from './utils';
import config from './config';
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 { GetPlayerResponse } from '../../types/get-player-response';
type CustomSongInfo = SongInfo & { trackId?: string };
const ffmpeg = createFFmpeg({
log: false,
logger() {
}, // Console.log,
progress() {
}, // Console.log,
});
const {
presets,
cropMaxWidth,
getFolder,
setBadge,
sendFeedback: sendFeedback_,
} = require('./utils');
const config = require('./config');
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();
/** @type {Innertube} */
let yt;
let win;
let playingUrl;
let yt: Innertube;
let win: BrowserWindow;
let playingUrl: string;
const sendError = (error, source) => {
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${error.cause.toString()}` : '';
const cause = error.cause ? `\n\n${String(error.cause)}` : '';
const message = `${error.toString()}${songNameMessage}${cause}`;
console.error(message);
@ -65,7 +71,7 @@ const sendError = (error, source) => {
});
};
module.exports = async (win_) => {
export default async (win_: BrowserWindow) => {
win = win_;
injectCSS(win.webContents, join(__dirname, 'style.css'));
@ -73,52 +79,48 @@ module.exports = async (win_) => {
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-song', (_, url: string) => downloadSong(url));
ipcMain.on('video-src-changed', (_, data: string) => {
playingUrl = (JSON.parse(data) as GetPlayerResponse).microformat.microformatDataRenderer.urlCanonical;
});
ipcMain.on('download-playlist-request', async (_event, url) =>
ipcMain.on('download-playlist-request', async (_event, url: string) =>
downloadPlaylist(url),
);
};
module.exports.downloadSong = downloadSong;
module.exports.downloadPlaylist = downloadPlaylist;
async function downloadSong(
url,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {
export async function downloadSong(
url: string,
playlistFolder: string | undefined = undefined,
trackId: string | undefined = undefined,
increasePlaylistProgress: (value: number) => void = () => {
},
) {
let resolvedName;
try {
await downloadSongUnsafe(
url,
(name) => resolvedName = name,
(name: string) => resolvedName = name,
playlistFolder,
trackId,
increasePlaylistProgress,
);
} catch (error) {
sendError(error, resolvedName || url);
} catch (error: unknown) {
sendError(error as Error, resolvedName || url);
}
}
async function downloadSongUnsafe(
url,
setName,
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {
url: string,
setName: (name: string) => void,
playlistFolder: string | undefined = undefined,
trackId: string | undefined = undefined,
increasePlaylistProgress: (value: number) => void = () => {
},
) {
const sendFeedback = (message, progress) => {
const sendFeedback = (message: unknown, progress?: number) => {
if (!playlistFolder) {
sendFeedback_(win, message);
if (!isNaN(progress)) {
if (progress && !isNaN(progress)) {
win.setProgressBar(progress);
}
}
@ -127,7 +129,9 @@ async function downloadSongUnsafe(
sendFeedback('Downloading...', 2);
const id = getVideoId(url);
let info = await yt.music.getInfo(id);
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');
@ -164,19 +168,19 @@ async function downloadSongUnsafe(
}
if (playabilityStatus.status === 'UNPLAYABLE') {
/**
* @typedef {import('youtubei.js/dist/src/parser/classes/PlayerErrorMessage').default} PlayerErrorMessage
* @type {PlayerErrorMessage}
*/
const errorScreen = playabilityStatus.error_screen;
const errorScreen = playabilityStatus.error_screen as PlayerErrorMessage | null;
throw new Error(
`[${playabilityStatus.status}] ${errorScreen.reason.text}: ${errorScreen.subreason.text}`,
`[${playabilityStatus.status}] ${errorScreen?.reason.text}: ${errorScreen?.subreason.text}`,
);
}
const extension = presets[config.get('preset')]?.extension || 'mp3';
const preset = config.get('preset') ?? 'mp3';
let presetSetting: { extension: string; ffmpegArgs: string[] } | null = null;
if (preset === 'opus') {
presetSetting = presets[preset];
}
const filename = filenamify(`${name}.${extension}`, {
const filename = filenamify(`${name}.${presetSetting?.extension ?? 'mp3'}`, {
replacement: '_',
maxLength: 255,
});
@ -187,7 +191,7 @@ async function downloadSongUnsafe(
return;
}
const downloadOptions = {
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
@ -197,7 +201,7 @@ async function downloadSongUnsafe(
const stream = await info.download(downloadOptions);
console.info(
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`,
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`,
);
const iterableStream = Utils.streamToIterable(stream);
@ -206,10 +210,10 @@ async function downloadSongUnsafe(
mkdirSync(dir);
}
if (presets[config.get('preset')]) {
if (presetSetting) {
const file = createWriteStream(filePath);
let downloaded = 0;
const total = format.content_length;
const total: number = format.content_length ?? 1;
for await (const chunk of iterableStream) {
downloaded += chunk.length;
@ -223,18 +227,23 @@ async function downloadSongUnsafe(
await ffmpegWriteTags(
filePath,
metadata,
presets[config.get('preset')]?.ffmpegArgs,
presetSetting.ffmpegArgs,
);
sendFeedback(null, -1);
} else {
const fileBuffer = await iterableStreamToMP3(
iterableStream,
metadata,
format.content_length,
format.content_length ?? 0,
sendFeedback,
increasePlaylistProgress,
);
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
if (fileBuffer) {
const buffer = await writeID3(Buffer.from(fileBuffer), metadata, sendFeedback);
if (buffer) {
writeFileSync(filePath, buffer);
}
}
}
sendFeedback(null, -1);
@ -242,11 +251,11 @@ async function downloadSongUnsafe(
}
async function iterableStreamToMP3(
stream,
metadata,
contentLength,
sendFeedback,
increasePlaylistProgress = () => {
stream: AsyncGenerator<Uint8Array, void>,
metadata: CustomSongInfo,
contentLength: number,
sendFeedback: (str: string, value?: number) => void,
increasePlaylistProgress: (value: number) => void = () => {
},
) {
const chunks = [];
@ -294,66 +303,69 @@ async function iterableStreamToMP3(
sendFeedback('Saving…');
return ffmpeg.FS('readFile', `${safeVideoName}.mp3`);
} catch (error) {
sendError(error, safeVideoName);
} catch (error: unknown) {
sendError(error as Error, safeVideoName);
} finally {
releaseFFmpegMutex();
}
}
const getCoverBuffer = cache(async (url) => {
const getCoverBuffer = cache(async (url: string) => {
const nativeImage = cropMaxWidth(await getImage(url));
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
});
async function writeID3(buffer, metadata, sendFeedback) {
async function writeID3(buffer: Buffer, metadata: CustomSongInfo, sendFeedback: (str: string, value?: number) => void) {
try {
sendFeedback('Writing ID3 tags...');
const coverBuffer = await getCoverBuffer(metadata.image);
const writer = new ID3Writer(buffer);
const tags: NodeID3.Tags = {};
// Create the metadata tags
writer.setFrame('TIT2', metadata.title).setFrame('TPE1', [metadata.artist]);
tags.title = metadata.title;
tags.artist = metadata.artist;
if (metadata.album) {
writer.setFrame('TALB', metadata.album);
tags.album = metadata.album;
}
const coverBuffer = await getCoverBuffer(metadata.imageSrc ?? '');
if (coverBuffer) {
writer.setFrame('APIC', {
type: 3,
data: coverBuffer,
description: '',
});
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) {
writer.setFrame('USLT', {
description: '',
lyrics,
});
tags.unsynchronisedLyrics = {
language: '',
text: lyrics,
};
}
}
if (metadata.trackId) {
writer.setFrame('TRCK', metadata.trackId);
tags.trackNumber = metadata.trackId;
}
writer.addTag();
return Buffer.from(writer.arrayBuffer);
} catch (error) {
sendError(error, `${metadata.artist} - ${metadata.title}`);
return NodeID3.write(tags, buffer);
} catch (error: unknown) {
sendError(error as Error, `${metadata.artist} - ${metadata.title}`);
return null;
}
}
async function downloadPlaylist(givenUrl) {
export async function downloadPlaylist(givenUrl?: string | URL) {
try {
givenUrl = new URL(givenUrl);
givenUrl = new URL(givenUrl ?? '');
} catch {
givenUrl = undefined;
return;
}
const playlistId
@ -366,18 +378,18 @@ async function downloadPlaylist(givenUrl) {
return;
}
const sendFeedback = (message) => sendFeedback_(win, message);
const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
console.log(`trying to get playlist ID: '${playlistId}'`);
sendFeedback('Getting playlist info…');
let playlist;
let playlist: ytpl.Result;
try {
playlist = await ytpl(playlistId, {
limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY,
});
} catch (error) {
} catch (error: unknown) {
sendError(
`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${error}`,
Error(`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(error)}`),
);
return;
}
@ -432,8 +444,8 @@ async function downloadPlaylist(givenUrl) {
const progressStep = 1 / playlist.items.length;
const increaseProgress = (itemPercentage) => {
const currentProgress = (counter - 1) / playlist.items.length;
const increaseProgress = (itemPercentage: number) => {
const currentProgress = (counter - 1) / (playlist.items.length ?? 1);
const newProgress = currentProgress + (progressStep * itemPercentage);
win.setProgressBar(newProgress);
};
@ -445,11 +457,11 @@ async function downloadPlaylist(givenUrl) {
await downloadSong(
song.url,
playlistFolder,
trackId,
trackId?.toString(),
increaseProgress,
).catch((error) =>
sendError(
`Error downloading "${song.author.name} - ${song.title}":\n ${error}`,
new Error(`Error downloading "${song.author.name} - ${song.title}":\n ${error}`)
),
);
@ -457,8 +469,8 @@ async function downloadPlaylist(givenUrl) {
setBadge(playlist.items.length - counter);
counter++;
}
} catch (error) {
sendError(error);
} catch (error: unknown) {
sendError(error as Error);
} finally {
win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge counter
@ -466,7 +478,7 @@ async function downloadPlaylist(givenUrl) {
}
}
async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
async function ffmpegWriteTags(filePath: string, metadata: CustomSongInfo, ffmpegArgs: string[] = []) {
const releaseFFmpegMutex = await ffmpegMutex.acquire();
try {
@ -481,16 +493,16 @@ async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
...ffmpegArgs,
filePath,
);
} catch (error) {
sendError(error);
} catch (error: unknown) {
sendError(error as Error);
} finally {
releaseFFmpegMutex();
}
}
function getFFmpegMetadataArgs(metadata) {
function getFFmpegMetadataArgs(metadata: CustomSongInfo) {
if (!metadata) {
return;
return [];
}
return [
@ -504,7 +516,7 @@ function getFFmpegMetadataArgs(metadata) {
// Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = (aURL) => {
const getPlaylistID = (aURL: URL) => {
const result
= aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
@ -514,7 +526,7 @@ const getPlaylistID = (aURL) => {
return result;
};
const getVideoId = (url) => {
const getVideoId = (url: URL | string): string | null => {
if (typeof url === 'string') {
url = new URL(url);
}
@ -522,18 +534,21 @@ const getVideoId = (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,
const getMetadata = (info: TrackInfo): CustomSongInfo => ({
videoId: info.basic_info.id!,
title: cleanupName(info.basic_info.title!),
artist: cleanupName(info.basic_info.author!),
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
album: (info.player_overlays?.browser_media_session as any)?.album?.text as string | undefined,
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) => {
const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
const innertube = await Innertube.create({
clientType: ClientType.TV_EMBEDDED,
client_type: ClientType.TV_EMBEDDED,
generate_session_locally: true,
retrieve_player: true,
});

View File

@ -1,4 +0,0 @@
const { PluginConfig } = require('../../config/dynamic');
const config = new PluginConfig('downloader');
module.exports = { ...config };

View File

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

View File

@ -1,11 +1,12 @@
const { ipcRenderer } = require('electron');
import { ipcRenderer } from 'electron';
const { defaultConfig } = require('../../config');
const { getSongMenu } = require('../../providers/dom-elements');
const { ElementFromFile, templatePath } = require('../utils');
import defaultConfig from '../../config/defaults';
import { getSongMenu } from '../../providers/dom-elements';
import { ElementFromFile, templatePath } from '../utils';
import { getSongInfo } from '../../providers/song-info-front';
let menu = null;
let progress = null;
let menu: Element | null = null;
let progress: Element | null = null;
const downloadButton = ElementFromFile(
templatePath(__dirname, 'download.html'),
);
@ -24,7 +25,7 @@ const menuObserver = new MutationObserver(() => {
return;
}
const menuUrl = document.querySelector('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint')?.href;
const menuUrl = (document.querySelector('tp-yt-paper-listbox [tabindex="0"] #navigation-endpoint') as HTMLAnchorElement | undefined)?.href;
if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return;
}
@ -42,7 +43,7 @@ const menuObserver = new MutationObserver(() => {
// TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld("downloader", {
// download: () => {
global.download = () => {
export const download = () => {
let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint')
@ -57,21 +58,21 @@ global.download = () => {
return;
}
} else {
videoUrl = global.songInfo.url || window.location.href;
videoUrl = getSongInfo().url || window.location.href;
}
ipcRenderer.send('download-song', videoUrl);
};
module.exports = () => {
export default () => {
document.addEventListener('apiLoaded', () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container'), {
menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true,
subtree: true,
});
}, { once: true, passive: true });
ipcRenderer.on('downloader-feedback', (_, feedback) => {
ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
if (progress) {
progress.innerHTML = feedback || 'Download';
} else {

View File

@ -1,10 +1,12 @@
const { dialog } = require('electron');
import { dialog } from 'electron';
const { downloadPlaylist } = require('./back');
const { defaultMenuDownloadLabel, getFolder, presets } = require('./utils');
const config = require('./config');
import { downloadPlaylist } from './back';
import { defaultMenuDownloadLabel, getFolder, presets } from './utils';
import config from './config';
module.exports = () => [
import { MenuTemplate } from '../../menu';
export default (): MenuTemplate => [
{
label: defaultMenuDownloadLabel,
click: () => downloadPlaylist(),

View File

@ -1,14 +1,14 @@
const { app } = require('electron');
const is = require('electron-is');
import { app, BrowserWindow } from 'electron';
import is from 'electron-is';
module.exports.getFolder = (customFolder) => customFolder || app.getPath('downloads');
module.exports.defaultMenuDownloadLabel = 'Download playlist';
export const getFolder = (customFolder: string) => customFolder || app.getPath('downloads');
export const defaultMenuDownloadLabel = 'Download playlist';
module.exports.sendFeedback = (win, message) => {
export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
win.webContents.send('downloader-feedback', message);
};
module.exports.cropMaxWidth = (image) => {
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) {
@ -24,7 +24,7 @@ module.exports.cropMaxWidth = (image) => {
};
// Presets for FFmpeg
module.exports.presets = {
export const presets = {
'None (defaults to mp3)': undefined,
'opus': {
extension: 'opus',
@ -32,7 +32,7 @@ module.exports.presets = {
},
};
module.exports.setBadge = (n) => {
export const setBadge = (n: number) => {
if (is.linux() || is.macOS()) {
app.setBadgeCount(n);
}

View File

@ -1,23 +0,0 @@
const path = require('node:path');
const electronLocalshortcut = require('electron-localshortcut');
const { setupTitlebar, attachTitlebarToWindow } = require('custom-electron-titlebar/main');
const { injectCSS } = require('../utils');
setupTitlebar();
// Tracks menu visibility
module.exports = (win) => {
// Css for custom scrollbar + disable drag area(was causing bugs)
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
win.once('ready-to-show', () => {
attachTitlebarToWindow(win);
electronLocalshortcut.register(win, '`', () => {
win.webContents.send('toggleMenu');
});
});
};

View File

@ -0,0 +1,27 @@
import path from 'node:path';
import { register } from 'electron-localshortcut';
// eslint-disable-next-line import/no-unresolved
import { attachTitlebarToWindow, setupTitlebar } from 'custom-electron-titlebar/main';
import { BrowserWindow } from 'electron';
import { injectCSS } from '../utils';
setupTitlebar();
// Tracks menu visibility
module.exports = (win: BrowserWindow) => {
// Css for custom scrollbar + disable drag area(was causing bugs)
injectCSS(win.webContents, path.join(__dirname, 'style.css'));
win.once('ready-to-show', () => {
attachTitlebarToWindow(win);
register(win, '`', () => {
win.webContents.send('toggleMenu');
});
});
};

View File

@ -0,0 +1,9 @@
declare module 'custom-electron-titlebar' {
// eslint-disable-next-line import/no-unresolved
import OriginalTitlebar from 'custom-electron-titlebar/dist/titlebar';
// eslint-disable-next-line import/no-unresolved
import { Color as OriginalColor } from 'custom-electron-titlebar/dist/vs/base/common/color';
export const Color: typeof OriginalColor;
export const Titlebar: typeof OriginalTitlebar;
}

View File

@ -1,78 +0,0 @@
const { ipcRenderer } = require('electron');
const { Titlebar, Color } = require('custom-electron-titlebar');
const config = require('../../config');
const { isEnabled } = require('../../config/plugins');
function $(selector) {
return document.querySelector(selector);
}
module.exports = () => {
const visible = () => Boolean($('.cet-menubar').firstChild);
const bar = new Titlebar({
icon: 'https://cdn-icons-png.flaticon.com/512/5358/5358672.png',
backgroundColor: Color.fromHex('#050505'),
itemBackgroundColor: Color.fromHex('#1d1d1d'),
svgColor: Color.WHITE,
menu: config.get('options.hideMenu') ? null : undefined,
});
bar.updateTitle(' ');
document.title = 'Youtube Music';
const toggleMenu = () => {
if (visible()) {
bar.updateMenu(null);
} else {
bar.refreshMenu();
}
};
$('.cet-window-icon').addEventListener('click', toggleMenu);
ipcRenderer.on('toggleMenu', toggleMenu);
ipcRenderer.on('refreshMenu', () => {
if (visible()) {
bar.refreshMenu();
}
});
if (isEnabled('picture-in-picture')) {
ipcRenderer.on('pip-toggle', () => {
bar.refreshMenu();
});
}
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
document.addEventListener('apiLoaded', () => {
setNavbarMargin();
const playPageObserver = new MutationObserver(setNavbarMargin);
playPageObserver.observe($('ytmusic-app-layout'), { attributeFilter: ['player-page-open_', 'playerPageOpen_'] });
setupSearchOpenObserver();
setupMenuOpenObserver();
}, { once: true, passive: true });
};
function setupSearchOpenObserver() {
const searchOpenObserver = new MutationObserver((mutations) => {
$('#nav-bar-background').style.webkitAppRegion
= mutations[0].target.opened ? 'no-drag' : 'drag';
});
searchOpenObserver.observe($('ytmusic-search-box'), { attributeFilter: ['opened'] });
}
function setupMenuOpenObserver() {
const menuOpenObserver = new MutationObserver(() => {
$('#nav-bar-background').style.webkitAppRegion
= [...$('.cet-menubar').childNodes].some((c) => c.classList.contains('open'))
? 'no-drag' : 'drag';
});
menuOpenObserver.observe($('.cet-menubar'), { subtree: true, attributeFilter: ['class'] });
}
function setNavbarMargin() {
$('#nav-bar-background').style.right
= $('ytmusic-app-layout').playerPageOpen_
? '0px'
: '12px';
}

View File

@ -0,0 +1,96 @@
import { ipcRenderer, Menu } from 'electron';
// eslint-disable-next-line import/no-unresolved
import { Color, Titlebar } from 'custom-electron-titlebar';
import config from '../../config';
import { isEnabled } from '../../config/plugins';
function $(selector: string) {
return document.querySelector(selector);
}
module.exports = () => {
const visible = () => Boolean($('.cet-menubar')?.firstChild);
const bar = new Titlebar({
icon: 'https://cdn-icons-png.flaticon.com/512/5358/5358672.png',
backgroundColor: Color.fromHex('#050505'),
itemBackgroundColor: Color.fromHex('#1d1d1d') ,
svgColor: Color.WHITE,
menu: config.get('options.hideMenu') ? null as unknown as Menu : undefined,
});
bar.updateTitle(' ');
document.title = 'Youtube Music';
const toggleMenu = () => {
if (visible()) {
bar.updateMenu(null as unknown as Menu);
} else {
bar.refreshMenu();
}
};
$('.cet-window-icon')?.addEventListener('click', toggleMenu);
ipcRenderer.on('toggleMenu', toggleMenu);
ipcRenderer.on('refreshMenu', () => {
if (visible()) {
bar.refreshMenu();
}
});
if (isEnabled('picture-in-picture')) {
ipcRenderer.on('pip-toggle', () => {
bar.refreshMenu();
});
}
// Increases the right margin of Navbar background when the scrollbar is visible to avoid blocking it (z-index doesn't affect it)
document.addEventListener('apiLoaded', () => {
setNavbarMargin();
const playPageObserver = new MutationObserver(setNavbarMargin);
const appLayout = $('ytmusic-app-layout');
if (appLayout) {
playPageObserver.observe(appLayout, { attributeFilter: ['player-page-open_', 'playerPageOpen_'] });
}
setupSearchOpenObserver();
setupMenuOpenObserver();
}, { once: true, passive: true });
};
function setupSearchOpenObserver() {
const searchOpenObserver = new MutationObserver((mutations) => {
($('#nav-bar-background') as HTMLElement)
.style
.setProperty(
'-webkit-app-region',
(mutations[0].target as HTMLElement & { opened: boolean }).opened ? 'no-drag' : 'drag',
);
});
const searchBox = $('ytmusic-search-box');
if (searchBox) {
searchOpenObserver.observe(searchBox, { attributeFilter: ['opened'] });
}
}
function setupMenuOpenObserver() {
const cetMenubar = $('.cet-menubar');
if (cetMenubar) {
const menuOpenObserver = new MutationObserver(() => {
($('#nav-bar-background') as HTMLElement)
.style
.setProperty(
'-webkit-app-region',
Array.from(cetMenubar.childNodes).some((c) => (c as HTMLElement).classList.contains('open')) ? 'no-drag' : 'drag',
);
});
menuOpenObserver.observe(cetMenubar, { subtree: true, attributeFilter: ['class'] });
}
}
function setNavbarMargin() {
const navBarBackground = $('#nav-bar-background') as HTMLElement;
navBarBackground.style.right
= ($('ytmusic-app-layout') as HTMLElement & { playerPageOpen_: boolean }).playerPageOpen_
? '0px'
: '12px';
}

View File

@ -1,32 +1,41 @@
const { shell, net } = require('electron');
const md5 = require('md5');
import { BrowserWindow, net, shell } from 'electron';
import md5 from 'md5';
const { setOptions } = require('../../config/plugins');
const registerCallback = require('../../providers/song-info');
const defaultConfig = require('../../config/defaults');
import { setOptions } from '../../config/plugins';
import registerCallback, { SongInfo } from '../../providers/song-info';
import defaultConfig from '../../config/defaults';
import config from '../../config';
const createFormData = (parameters) => {
const LastFMOptionsObj = config.get('plugins.last-fm');
type LastFMOptions = typeof LastFMOptionsObj;
interface LastFmData {
method: string,
timestamp?: number,
}
const createFormData = (parameters: Record<string, unknown>) => {
// Creates the body for in the post request
const formData = new URLSearchParams();
for (const key in parameters) {
formData.append(key, parameters[key]);
formData.append(key, String(parameters[key]));
}
return formData;
};
const createQueryString = (parameters, apiSignature) => {
const createQueryString = (parameters: Record<string, unknown>, apiSignature: string) => {
// Creates a querystring
const queryData = [];
parameters.api_sig = apiSignature;
for (const key in parameters) {
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(parameters[key])}`);
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(parameters[key]))}`);
}
return '?' + queryData.join('&');
};
const createApiSig = (parameters, secret) => {
const createApiSig = (parameters: Record<string, unknown>, secret: string) => {
// This function creates the api signature, see: https://www.last.fm/api/authspec
const keys = [];
for (const key in parameters) {
@ -40,7 +49,7 @@ const createApiSig = (parameters, secret) => {
continue;
}
sig += `${key}${parameters[key]}`;
sig += `${key}${String(parameters[key])}`;
}
sig += secret;
@ -48,7 +57,7 @@ const createApiSig = (parameters, secret) => {
return sig;
};
const createToken = async ({ apiKey, apiRoot, secret }) => {
const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFMOptions) => {
// Creates and stores the auth token
const data = {
method: 'auth.gettoken',
@ -56,20 +65,18 @@ const createToken = async ({ apiKey, apiRoot, secret }) => {
format: 'json',
};
const apiSigature = createApiSig(data, secret);
let response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`);
response = await response.json();
return response?.token;
const response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`);
const json = await response.json() as Record<string, string>;
return json?.token;
};
const authenticate = async (config) => {
const authenticateAndGetToken = async (config: LastFMOptions) => {
// Asks the user for authentication
config.token = await createToken(config);
setOptions('last-fm', config);
shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
return config;
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
return await createToken(config);
};
const getAndSetSessionKey = async (config) => {
const getAndSetSessionKey = async (config: LastFMOptions) => {
// Get and store the session key
const data = {
api_key: config.api_key,
@ -78,18 +85,25 @@ const getAndSetSessionKey = async (config) => {
token: config.token,
};
const apiSignature = createApiSig(data, config.secret);
let res = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`);
res = await res.json();
if (res.error) {
await authenticate(config);
const response = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`);
const json = await response.json() as {
error?: string,
session?: {
key: string,
}
};
if (json.error) {
config.token = await authenticateAndGetToken(config);
setOptions('last-fm', config);
}
if (json.session) {
config.session_key = json?.session?.key;
setOptions('last-fm', config);
}
config.session_key = res?.session?.key;
setOptions('last-fm', config);
return config;
};
const postSongDataToAPI = async (songInfo, config, data) => {
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data: LastFmData) => {
// This sends a post request to the api, and adds the common data
if (!config.session_key) {
await getAndSetSessionKey(config);
@ -101,6 +115,7 @@ const postSongDataToAPI = async (songInfo, config, data) => {
artist: songInfo.artist,
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
api_key: config.api_key,
api_sig: '',
sk: config.session_key,
format: 'json',
...data,
@ -108,26 +123,32 @@ const postSongDataToAPI = async (songInfo, config, data) => {
postData.api_sig = createApiSig(postData, config.secret);
net.fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: createFormData(postData) })
.catch((error) => {
if (error.response.data.error === 9) {
.catch(async (error: {
response?: {
data?: {
error: number,
}
}
}) => {
if (error?.response?.data?.error === 9) {
// Session key is invalid, so remove it from the config and reauthenticate
config.session_key = undefined;
config.token = await authenticateAndGetToken(config);
setOptions('last-fm', config);
authenticate(config);
}
});
};
const addScrobble = (songInfo, config) => {
const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => {
// This adds one scrobbled song to last.fm
const data = {
method: 'track.scrobble',
timestamp: Math.trunc((Date.now() - songInfo.elapsedSeconds) / 1000),
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
};
postSongDataToAPI(songInfo, config, data);
};
const setNowPlaying = (songInfo, config) => {
const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
// This sets the now playing status in last.fm
const data = {
method: 'track.updateNowPlaying',
@ -136,9 +157,9 @@ const setNowPlaying = (songInfo, config) => {
};
// This will store the timeout that will trigger addScrobble
let scrobbleTimer;
let scrobbleTimer: NodeJS.Timeout | undefined;
const lastfm = async (_win, config) => {
const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
if (!config.api_root) {
// Settings are not present, creating them with the default values
config = defaultConfig.plugins['last-fm'];
@ -156,11 +177,11 @@ const lastfm = async (_win, config) => {
clearTimeout(scrobbleTimer);
if (!songInfo.isPaused) {
setNowPlaying(songInfo, config);
// Scrobble when the song is half way through, or has passed the 4 minute mark
// Scrobble when the song is halfway through, or has passed the 4-minute mark
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
if (scrobbleTime > songInfo.elapsedSeconds) {
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
// Scrobble still needs to happen
const timeToWait = (scrobbleTime - songInfo.elapsedSeconds) * 1000;
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
}
}

View File

@ -1,36 +1,43 @@
const { join } = require('node:path');
import { join } from 'node:path';
const { ipcMain, net } = require('electron');
const is = require('electron-is');
const { convert } = require('html-to-text');
import { BrowserWindow, ipcMain, net } from 'electron';
import is from 'electron-is';
import { convert } from 'html-to-text';
const { cleanupName } = require('../../providers/song-info');
const { injectCSS } = require('../utils');
import { GetGeniusLyric } from './types';
import { cleanupName, SongInfo } from '../../providers/song-info';
import { injectCSS } from '../utils';
import config from '../../config';
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false;
module.exports = async (win, options) => {
const LyricGeniusTypeObj = config.get('plugins.lyric-genius');
export type LyricGeniusType = typeof LyricGeniusTypeObj;
export default (win: BrowserWindow, options: LyricGeniusType) => {
if (options.romanizedLyrics) {
revRomanized = true;
}
injectCSS(win.webContents, join(__dirname, 'style.css'));
ipcMain.on('search-genius-lyrics', async (event, extractedSongInfo) => {
const metadata = JSON.parse(extractedSongInfo);
event.returnValue = await fetchFromGenius(metadata);
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: string) => {
const metadata = JSON.parse(extractedSongInfo) as SongInfo;
return await fetchFromGenius(metadata);
});
};
const toggleRomanized = () => {
export const toggleRomanized = () => {
revRomanized = !revRomanized;
};
const fetchFromGenius = async (metadata) => {
export const fetchFromGenius = async (metadata: SongInfo) => {
const songTitle = `${cleanupName(metadata.title)}`;
const songArtist = `${cleanupName(metadata.artist)}`;
let lyrics;
let lyrics: string | null;
/* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal
Genius Lyrics behavior is observed.
@ -46,7 +53,7 @@ const fetchFromGenius = async (metadata) => {
/* If the romanization toggle is on, and we did not detect any characters in the title or artist, we do a check
for characters in the lyrics themselves. If this check proves true, we search for Romanized lyrics.
*/
if (revRomanized && !hasAsianChars && eastAsianChars.test(lyrics)) {
if (revRomanized && !hasAsianChars && lyrics && eastAsianChars.test(lyrics)) {
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
}
@ -58,7 +65,7 @@ const fetchFromGenius = async (metadata) => {
* @param {*} queryString
* @returns The lyrics of the first song found using the Genius-Lyrics API
*/
const getLyricsList = async (queryString) => {
const getLyricsList = async (queryString: string): Promise<string | null> => {
const response = await net.fetch(
`https://genius.com/api/search/multi?per_page=5&q=${encodeURIComponent(queryString)}`,
);
@ -69,17 +76,17 @@ const getLyricsList = async (queryString) => {
/* Fetch the first URL with the api, giving a collection of song results.
Pick the first song, parsing the json given by the API.
*/
const info = await response.json();
let url = '';
try {
url = info.response.sections.find((section) => section.type === 'song')
.hits[0].result.url;
} catch {
const info = await response.json() as GetGeniusLyric;
const url = info
.response
.sections
.find((section) => section.type === 'song')?.hits[0].result.url;
if (url) {
return await getLyrics(url);
} else {
return null;
}
const lyrics = await getLyrics(url);
return lyrics;
};
/**
@ -87,7 +94,7 @@ const getLyricsList = async (queryString) => {
* @param {*} url
* @returns The lyrics of the song URL provided, null if none
*/
const getLyrics = async (url) => {
const getLyrics = async (url: string): Promise<string | null> => {
const response = await net.fetch(url);
if (!response.ok) {
return null;
@ -116,6 +123,3 @@ const getLyrics = async (url) => {
},
});
};
module.exports.toggleRomanized = toggleRomanized;
module.exports.fetchFromGenius = fetchFromGenius;

View File

@ -1,8 +1,8 @@
const { ipcRenderer } = require('electron');
const is = require('electron-is');
import { ipcRenderer } from 'electron';
import is from 'electron-is';
module.exports = () => {
ipcRenderer.on('update-song-info', (_, extractedSongInfo) => setTimeout(() => {
export default () => {
ipcRenderer.on('update-song-info', (_, extractedSongInfo: string) => setTimeout(async () => {
const tabList = document.querySelectorAll('tp-yt-paper-tab');
const tabs = {
upNext: tabList[0],
@ -17,10 +17,10 @@ module.exports = () => {
let hasLyrics = true;
const lyrics = ipcRenderer.sendSync(
const lyrics = await ipcRenderer.invoke(
'search-genius-lyrics',
extractedSongInfo,
);
) as string;
if (!lyrics) {
// Delete previous lyrics if tab is open and couldn't get new lyrics
checkLyricsContainer(() => {
@ -40,17 +40,21 @@ module.exports = () => {
checkLyricsContainer();
tabs.lyrics.addEventListener('click', () => {
const lyricsTabHandler = () => {
const tabContainer = document.querySelector('ytmusic-tab-renderer');
const observer = new MutationObserver((_, observer) => {
checkLyricsContainer(() => observer.disconnect());
});
observer.observe(tabContainer, {
attributes: true,
childList: true,
subtree: true,
});
});
if (tabContainer) {
const observer = new MutationObserver((_, observer) => {
checkLyricsContainer(() => observer.disconnect());
});
observer.observe(tabContainer, {
attributes: true,
childList: true,
subtree: true,
});
}
};
tabs.lyrics.addEventListener('click', lyricsTabHandler);
function checkLyricsContainer(callback = () => {
}) {
@ -63,7 +67,7 @@ module.exports = () => {
}
}
function setLyrics(lyricsContainer) {
function setLyrics(lyricsContainer: Element) {
lyricsContainer.innerHTML = `<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
${
hasLyrics
@ -74,15 +78,20 @@ module.exports = () => {
</div>
<yt-formatted-string class="footer style-scope ytmusic-description-shelf-renderer" style="align-self: baseline"></yt-formatted-string>`;
if (hasLyrics) {
lyricsContainer.querySelector('.footer').textContent = 'Source: Genius';
enableLyricsTab();
const footer = lyricsContainer.querySelector('.footer');
if (footer) {
footer.textContent = 'Source: Genius';
enableLyricsTab();
}
}
}
function setTabsOnclick(callback) {
const defaultHandler = () => {};
function setTabsOnclick(callback: EventListenerOrEventListenerObject | undefined) {
for (const tab of [tabs.upNext, tabs.discover]) {
if (tab) {
tab.addEventListener('click', callback);
tab.addEventListener('click', callback ?? defaultHandler);
}
}
}

View File

@ -1,16 +0,0 @@
const { toggleRomanized } = require('./back');
const { setOptions } = require('../../config/plugins');
module.exports = (win, options) => [
{
label: 'Romanized Lyrics',
type: 'checkbox',
checked: options.romanizedLyrics,
click(item) {
options.romanizedLyrics = item.checked;
setOptions('lyrics-genius', options);
toggleRomanized();
},
},
];

View File

@ -0,0 +1,18 @@
import { BrowserWindow, MenuItem } from 'electron';
import { LyricGeniusType, toggleRomanized } from './back';
import { setOptions } from '../../config/plugins';
module.exports = (win: BrowserWindow, options: LyricGeniusType) => [
{
label: 'Romanized Lyrics',
type: 'checkbox',
checked: options.romanizedLyrics,
click(item: MenuItem) {
options.romanizedLyrics = item.checked;
setOptions('lyrics-genius', options);
toggleRomanized();
},
},
];

View File

@ -0,0 +1,121 @@
export interface GetGeniusLyric {
meta: Meta;
response: Response;
}
export interface Meta {
status: number;
}
export interface Response {
sections: Section[];
}
export interface Section {
type: string;
hits: Hit[];
}
export interface Hit {
highlights: Highlight[];
index: Index;
type: Index;
result: Result;
}
export interface Highlight {
property: string;
value: string;
snippet: boolean;
ranges: Range[];
}
export interface Range {
start: number;
end: number;
}
export enum Index {
Album = 'album',
Lyric = 'lyric',
Song = 'song',
}
export interface Result {
_type: Index;
annotation_count?: number;
api_path: string;
artist_names?: string;
full_title: string;
header_image_thumbnail_url?: string;
header_image_url?: string;
id: number;
instrumental?: boolean;
lyrics_owner_id?: number;
lyrics_state?: LyricsState;
lyrics_updated_at?: number;
path?: string;
pyongs_count?: number | null;
relationships_index_url?: string;
release_date_components: ReleaseDateComponents;
release_date_for_display: string;
release_date_with_abbreviated_month_for_display?: string;
song_art_image_thumbnail_url?: string;
song_art_image_url?: string;
stats?: Stats;
title?: string;
title_with_featured?: string;
updated_by_human_at?: number;
url: string;
featured_artists?: string[];
primary_artist?: Artist;
cover_art_thumbnail_url?: string;
cover_art_url?: string;
name?: string;
name_with_artist?: string;
artist?: Artist;
}
export interface Artist {
_type: Type;
api_path: string;
header_image_url: string;
id: number;
image_url: string;
index_character: IndexCharacter;
is_meme_verified: boolean;
is_verified: boolean;
name: string;
slug: string;
url: string;
iq?: number;
}
// TODO: Add more types
export enum Type {
Artist = 'artist',
}
// TODO: Add more index characters
export enum IndexCharacter {
G = 'g',
Y = 'y',
}
// TODO: Add more states
export enum LyricsState {
Complete = 'complete',
}
export interface ReleaseDateComponents {
year: number;
month: number;
day: number | null;
}
export interface Stats {
unreviewed_annotations: number;
concurrents?: number;
hot: boolean;
pageviews?: number;
}

View File

@ -61,7 +61,7 @@ module.exports = (win: BrowserWindow) => {
}
data.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds);
data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0);
data.cover = songInfo.imageSrc ?? '';
data.cover_url = songInfo.imageSrc ?? '';
data.album_url = songInfo.imageSrc ?? '';