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

@ -3,6 +3,39 @@ export interface WindowSizeConfig {
height: number;
}
export interface DefaultConfig {
'window-size': {
width: number;
height: number;
}
'window-maximized': boolean;
'window-position': {
x: number;
y: number;
}
url: string;
options: {
tray: boolean;
appVisible: boolean;
autoUpdates: boolean;
alwaysOnTop: boolean;
hideMenu: boolean;
hideMenuWarned: boolean;
startAtLogin: boolean;
disableHardwareAcceleration: boolean;
removeUpgradeButton: boolean;
restartOnConfigChanges: boolean;
trayClickPlayPause: boolean;
autoResetAppCache: boolean;
resumeOnStart: boolean;
likeButtons: string;
proxy: string;
startingPage: string;
overrideUserAgent: boolean;
themes: string[];
}
}
const defaultConfig = {
'window-size': {
width: 1100,
@ -54,15 +87,22 @@ const defaultConfig = {
'downloader': {
enabled: false,
ffmpegArgs: [], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
downloadFolder: undefined, // Custom download folder (absolute path)
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
preset: 'mp3',
skipExisting: false,
playlistMaxItems: undefined as number | undefined,
},
'last-fm': {
enabled: false,
token: undefined as string | undefined, // Token used for authentication
session_key: undefined as string | undefined, // Session key used for scrobbling
api_root: 'http://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
secret: 'a5d2a36fdf64819290f6982481eaffa2',
},
'lyric-genius': {
romanizedLyrics: false,
},
'discord': {
enabled: false,
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect

47
package-lock.json generated
View File

@ -36,6 +36,7 @@
"keyboardevents-areequal": "0.2.2",
"md5": "2.3.0",
"mpris-service": "2.1.2",
"node-id3": "0.2.6",
"simple-youtube-age-restriction-bypass": "git+https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.9",
"vudio": "2.1.1",
"youtubei.js": "6.1.0",
@ -44,7 +45,10 @@
"devDependencies": {
"@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1",
"@types/electron-localshortcut": "^3.1.0",
"@types/howler": "^2.2.8",
"@types/html-to-text": "^9.0.1",
"@types/md5": "^2.3.2",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0",
@ -1169,6 +1173,15 @@
"@types/ms": "*"
}
},
"node_modules/@types/electron-localshortcut": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@types/electron-localshortcut/-/electron-localshortcut-3.1.0.tgz",
"integrity": "sha512-upKSXMxBPRdz5kmcXfdfn+hWH9PCAvwhyVozDXTIwwHQ1lUJcdSgGUfxOC1QBlnAPKPqcW/r4icWfMosKz8ibg==",
"dev": true,
"dependencies": {
"electron": "*"
}
},
"node_modules/@types/filesystem": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.32.tgz",
@ -1207,6 +1220,12 @@
"integrity": "sha512-7OK+cGHTWIDCOvBlEc61Lzj2tJhCpmeqiqdeNbZvTxLHluBMF6xz/2wjoQkK1M8mJIStp40OdPnkp8xIvwwsuw==",
"dev": true
},
"node_modules/@types/html-to-text": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/@types/html-to-text/-/html-to-text-9.0.1.tgz",
"integrity": "sha512-sHu702QGb0SP2F0Zt+CxdCmGZIZ0gEaaCjqOh/V4iba1wTxPVntEPOM/vHm5bel08TILhB3+OxUTkDJWnr/zHQ==",
"dev": true
},
"node_modules/@types/http-cache-semantics": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
@ -1232,6 +1251,12 @@
"@types/node": "*"
}
},
"node_modules/@types/md5": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@types/md5/-/md5-2.3.2.tgz",
"integrity": "sha512-v+JFDu96+UYJ3/UWzB0mEglIS//MZXgRaJ4ubUPwOM0gvLc/kcQ3TWNYwENEK7/EcXGQVrW8h/XqednSjBd/Og==",
"dev": true
},
"node_modules/@types/minimist": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz",
@ -6571,6 +6596,25 @@
"node": "^12.13 || ^14.13 || >=16"
}
},
"node_modules/node-id3": {
"version": "0.2.6",
"resolved": "https://registry.npmjs.org/node-id3/-/node-id3-0.2.6.tgz",
"integrity": "sha512-w8GuKXLlPpDjTxLowCt/uYMhRQzED3cg2GdSG1i6RSGKeDzPvxlXeLQuQInKljahPZ0aDnmyX7FX8BbJOM7REg==",
"dependencies": {
"iconv-lite": "0.6.2"
}
},
"node_modules/node-id3/node_modules/iconv-lite": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.2.tgz",
"integrity": "sha512-2y91h5OpQlolefMPmUlivelittSWy0rP+oYVpn6A7GwVHNE8AWzoYOBNmlwks3LobaJxgHCYZAnyNo2GgpNRNQ==",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/nopt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
@ -7783,8 +7827,7 @@
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"devOptional": true
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"node_modules/sanitize-filename": {
"version": "1.6.3",

View File

@ -98,7 +98,7 @@
"lint": "xo",
"changelog": "auto-changelog",
"plugins": "npm run plugin:adblocker && npm run plugin:bypass-age-restrictions",
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && node plugins/adblocker/blocker.ts",
"plugin:adblocker": "del-cli plugins/adblocker/ad-blocker-engine.bin && tsc plugins/adblocker/blocker.ts && node dist/plugins/adblocker/blocker.js",
"plugin:bypass-age-restrictions": "del-cli node_modules/simple-youtube-age-restriction-bypass/package.json && npm run generate:package simple-youtube-age-restriction-bypass",
"release:linux": "npm run clean && electron-builder --linux -p always -c.snap.publish=github",
"release:mac": "npm run clean && electron-builder --mac -p always",
@ -135,6 +135,7 @@
"keyboardevents-areequal": "0.2.2",
"md5": "2.3.0",
"mpris-service": "2.1.2",
"node-id3": "0.2.6",
"simple-youtube-age-restriction-bypass": "git+https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.9",
"vudio": "2.1.1",
"youtubei.js": "6.1.0",
@ -149,7 +150,10 @@
"devDependencies": {
"@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1",
"@types/electron-localshortcut": "^3.1.0",
"@types/howler": "^2.2.8",
"@types/html-to-text": "^9.0.1",
"@types/md5": "^2.3.2",
"@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0",

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 ?? '';

View File

@ -7,9 +7,10 @@ import { YoutubePlayer } from '../types/youtube-player';
import { GetState } from '../types/datahost-get-state';
let songInfo: SongInfo = {} as SongInfo;
export const getSongInfo = () => songInfo;
const $ = <E extends HTMLElement>(s: string): E => document.querySelector(s) as E;
const $$ = <E extends HTMLElement>(s: string): E[] => [...document.querySelectorAll(s)!] as E[];
const $$ = <E extends HTMLElement>(s: string): E[] => Array.from(document.querySelectorAll(s));
ipcRenderer.on('update-song-info', async (_, extractedSongInfo: string) => {
songInfo = JSON.parse(extractedSongInfo) as SongInfo;

View File

@ -9,16 +9,16 @@ export interface SongInfo {
title: string;
artist: string;
views: number;
uploadDate: string;
uploadDate?: string;
imageSrc?: string | null;
image?: Electron.NativeImage | null;
isPaused?: boolean;
songDuration: number;
elapsedSeconds: number;
url: string;
elapsedSeconds?: number;
url?: string;
album?: string | null;
videoId: string;
playlistId: string;
playlistId?: string;
}
// Fill songInfo with empty values

View File

@ -1,7 +1,7 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["dom"],
"lib": ["dom", "es2022"],
"module": "CommonJS",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,