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; 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 = { const defaultConfig = {
'window-size': { 'window-size': {
width: 1100, width: 1100,
@ -54,15 +87,22 @@ const defaultConfig = {
'downloader': { 'downloader': {
enabled: false, enabled: false,
ffmpegArgs: [], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s 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', preset: 'mp3',
skipExisting: false,
playlistMaxItems: undefined as number | undefined,
}, },
'last-fm': { 'last-fm': {
enabled: false, 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_root: 'http://ws.audioscrobbler.com/2.0/',
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123 api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
secret: 'a5d2a36fdf64819290f6982481eaffa2', secret: 'a5d2a36fdf64819290f6982481eaffa2',
}, },
'lyric-genius': {
romanizedLyrics: false,
},
'discord': { 'discord': {
enabled: false, enabled: false,
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect 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", "keyboardevents-areequal": "0.2.2",
"md5": "2.3.0", "md5": "2.3.0",
"mpris-service": "2.1.2", "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", "simple-youtube-age-restriction-bypass": "git+https://github.com/zerodytrash/Simple-YouTube-Age-Restriction-Bypass.git#v2.5.9",
"vudio": "2.1.1", "vudio": "2.1.1",
"youtubei.js": "6.1.0", "youtubei.js": "6.1.0",
@ -44,7 +45,10 @@
"devDependencies": { "devDependencies": {
"@playwright/test": "1.37.1", "@playwright/test": "1.37.1",
"@total-typescript/ts-reset": "0.5.1", "@total-typescript/ts-reset": "0.5.1",
"@types/electron-localshortcut": "^3.1.0",
"@types/howler": "^2.2.8", "@types/howler": "^2.2.8",
"@types/html-to-text": "^9.0.1",
"@types/md5": "^2.3.2",
"@types/youtube-player": "^5.5.7", "@types/youtube-player": "^5.5.7",
"@typescript-eslint/eslint-plugin": "6.5.0", "@typescript-eslint/eslint-plugin": "6.5.0",
"auto-changelog": "2.4.0", "auto-changelog": "2.4.0",
@ -1169,6 +1173,15 @@
"@types/ms": "*" "@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": { "node_modules/@types/filesystem": {
"version": "0.0.32", "version": "0.0.32",
"resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.32.tgz", "resolved": "https://registry.npmjs.org/@types/filesystem/-/filesystem-0.0.32.tgz",
@ -1207,6 +1220,12 @@
"integrity": "sha512-7OK+cGHTWIDCOvBlEc61Lzj2tJhCpmeqiqdeNbZvTxLHluBMF6xz/2wjoQkK1M8mJIStp40OdPnkp8xIvwwsuw==", "integrity": "sha512-7OK+cGHTWIDCOvBlEc61Lzj2tJhCpmeqiqdeNbZvTxLHluBMF6xz/2wjoQkK1M8mJIStp40OdPnkp8xIvwwsuw==",
"dev": true "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": { "node_modules/@types/http-cache-semantics": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
@ -1232,6 +1251,12 @@
"@types/node": "*" "@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": { "node_modules/@types/minimist": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.2.tgz",
@ -6571,6 +6596,25 @@
"node": "^12.13 || ^14.13 || >=16" "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": { "node_modules/nopt": {
"version": "6.0.0", "version": "6.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz",
@ -7783,8 +7827,7 @@
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
"devOptional": true
}, },
"node_modules/sanitize-filename": { "node_modules/sanitize-filename": {
"version": "1.6.3", "version": "1.6.3",

View File

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

View File

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

View File

@ -1,58 +1,64 @@
const {
import {
existsSync, existsSync,
mkdirSync, mkdirSync,
createWriteStream, createWriteStream,
writeFileSync, writeFileSync,
} = require('node:fs'); } from 'node:fs';
const { join } = require('node:path'); 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'); import NodeID3, { TagConstants } from 'node-id3';
const is = require('electron-is');
const { Innertube, UniversalCache, Utils, ClientType } = require('youtubei.js'); import PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
const ytpl = require('ytpl'); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid import { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
const filenamify = require('filenamify');
const ID3Writer = require('browser-id3-writer'); import TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
const { Mutex } = require('async-mutex');
const ffmpeg = require('@ffmpeg/ffmpeg').createFFmpeg({ 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, log: false,
logger() { logger() {
}, // Console.log, }, // Console.log,
progress() { progress() {
}, // Console.log, }, // 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(); const ffmpegMutex = new Mutex();
/** @type {Innertube} */ let yt: Innertube;
let yt; let win: BrowserWindow;
let win; let playingUrl: string;
let playingUrl;
const sendError = (error, source) => { const sendError = (error: Error, source?: string) => {
win.setProgressBar(-1); // Close progress bar win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge setBadge(0); // Close badge
sendFeedback_(win); // Reset feedback sendFeedback_(win); // Reset feedback
const songNameMessage = source ? `\nin ${source}` : ''; 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}`; const message = `${error.toString()}${songNameMessage}${cause}`;
console.error(message); console.error(message);
@ -65,7 +71,7 @@ const sendError = (error, source) => {
}); });
}; };
module.exports = async (win_) => { export default async (win_: BrowserWindow) => {
win = win_; win = win_;
injectCSS(win.webContents, join(__dirname, 'style.css')); injectCSS(win.webContents, join(__dirname, 'style.css'));
@ -73,52 +79,48 @@ module.exports = async (win_) => {
cache: new UniversalCache(false), cache: new UniversalCache(false),
generate_session_locally: true, generate_session_locally: true,
}); });
ipcMain.on('download-song', (_, url) => downloadSong(url)); ipcMain.on('download-song', (_, url: string) => downloadSong(url));
ipcMain.on('video-src-changed', async (_, data) => { ipcMain.on('video-src-changed', (_, data: string) => {
playingUrl playingUrl = (JSON.parse(data) as GetPlayerResponse).microformat.microformatDataRenderer.urlCanonical;
= JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
}); });
ipcMain.on('download-playlist-request', async (_event, url) => ipcMain.on('download-playlist-request', async (_event, url: string) =>
downloadPlaylist(url), downloadPlaylist(url),
); );
}; };
module.exports.downloadSong = downloadSong; export async function downloadSong(
module.exports.downloadPlaylist = downloadPlaylist; url: string,
playlistFolder: string | undefined = undefined,
async function downloadSong( trackId: string | undefined = undefined,
url, increasePlaylistProgress: (value: number) => void = () => {
playlistFolder = undefined,
trackId = undefined,
increasePlaylistProgress = () => {
}, },
) { ) {
let resolvedName; let resolvedName;
try { try {
await downloadSongUnsafe( await downloadSongUnsafe(
url, url,
(name) => resolvedName = name, (name: string) => resolvedName = name,
playlistFolder, playlistFolder,
trackId, trackId,
increasePlaylistProgress, increasePlaylistProgress,
); );
} catch (error) { } catch (error: unknown) {
sendError(error, resolvedName || url); sendError(error as Error, resolvedName || url);
} }
} }
async function downloadSongUnsafe( async function downloadSongUnsafe(
url, url: string,
setName, setName: (name: string) => void,
playlistFolder = undefined, playlistFolder: string | undefined = undefined,
trackId = undefined, trackId: string | undefined = undefined,
increasePlaylistProgress = () => { increasePlaylistProgress: (value: number) => void = () => {
}, },
) { ) {
const sendFeedback = (message, progress) => { const sendFeedback = (message: unknown, progress?: number) => {
if (!playlistFolder) { if (!playlistFolder) {
sendFeedback_(win, message); sendFeedback_(win, message);
if (!isNaN(progress)) { if (progress && !isNaN(progress)) {
win.setProgressBar(progress); win.setProgressBar(progress);
} }
} }
@ -127,7 +129,9 @@ async function downloadSongUnsafe(
sendFeedback('Downloading...', 2); sendFeedback('Downloading...', 2);
const id = getVideoId(url); 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) { if (!info) {
throw new Error('Video not found'); throw new Error('Video not found');
@ -164,19 +168,19 @@ async function downloadSongUnsafe(
} }
if (playabilityStatus.status === 'UNPLAYABLE') { if (playabilityStatus.status === 'UNPLAYABLE') {
/** const errorScreen = playabilityStatus.error_screen as PlayerErrorMessage | null;
* @typedef {import('youtubei.js/dist/src/parser/classes/PlayerErrorMessage').default} PlayerErrorMessage
* @type {PlayerErrorMessage}
*/
const errorScreen = playabilityStatus.error_screen;
throw new Error( 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: '_', replacement: '_',
maxLength: 255, maxLength: 255,
}); });
@ -187,7 +191,7 @@ async function downloadSongUnsafe(
return; return;
} }
const downloadOptions = { const downloadOptions: FormatOptions = {
type: 'audio', // Audio, video or video+audio type: 'audio', // Audio, video or video+audio
quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on. quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on.
format: 'any', // Media container format format: 'any', // Media container format
@ -197,7 +201,7 @@ async function downloadSongUnsafe(
const stream = await info.download(downloadOptions); const stream = await info.download(downloadOptions);
console.info( console.info(
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`, `Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`,
); );
const iterableStream = Utils.streamToIterable(stream); const iterableStream = Utils.streamToIterable(stream);
@ -206,10 +210,10 @@ async function downloadSongUnsafe(
mkdirSync(dir); mkdirSync(dir);
} }
if (presets[config.get('preset')]) { if (presetSetting) {
const file = createWriteStream(filePath); const file = createWriteStream(filePath);
let downloaded = 0; let downloaded = 0;
const total = format.content_length; const total: number = format.content_length ?? 1;
for await (const chunk of iterableStream) { for await (const chunk of iterableStream) {
downloaded += chunk.length; downloaded += chunk.length;
@ -223,18 +227,23 @@ async function downloadSongUnsafe(
await ffmpegWriteTags( await ffmpegWriteTags(
filePath, filePath,
metadata, metadata,
presets[config.get('preset')]?.ffmpegArgs, presetSetting.ffmpegArgs,
); );
sendFeedback(null, -1); sendFeedback(null, -1);
} else { } else {
const fileBuffer = await iterableStreamToMP3( const fileBuffer = await iterableStreamToMP3(
iterableStream, iterableStream,
metadata, metadata,
format.content_length, format.content_length ?? 0,
sendFeedback, sendFeedback,
increasePlaylistProgress, 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); sendFeedback(null, -1);
@ -242,11 +251,11 @@ async function downloadSongUnsafe(
} }
async function iterableStreamToMP3( async function iterableStreamToMP3(
stream, stream: AsyncGenerator<Uint8Array, void>,
metadata, metadata: CustomSongInfo,
contentLength, contentLength: number,
sendFeedback, sendFeedback: (str: string, value?: number) => void,
increasePlaylistProgress = () => { increasePlaylistProgress: (value: number) => void = () => {
}, },
) { ) {
const chunks = []; const chunks = [];
@ -294,66 +303,69 @@ async function iterableStreamToMP3(
sendFeedback('Saving…'); sendFeedback('Saving…');
return ffmpeg.FS('readFile', `${safeVideoName}.mp3`); return ffmpeg.FS('readFile', `${safeVideoName}.mp3`);
} catch (error) { } catch (error: unknown) {
sendError(error, safeVideoName); sendError(error as Error, safeVideoName);
} finally { } finally {
releaseFFmpegMutex(); releaseFFmpegMutex();
} }
} }
const getCoverBuffer = cache(async (url) => { const getCoverBuffer = cache(async (url: string) => {
const nativeImage = cropMaxWidth(await getImage(url)); const nativeImage = cropMaxWidth(await getImage(url));
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null; 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 { try {
sendFeedback('Writing ID3 tags...'); sendFeedback('Writing ID3 tags...');
const tags: NodeID3.Tags = {};
const coverBuffer = await getCoverBuffer(metadata.image);
const writer = new ID3Writer(buffer);
// Create the metadata 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) { if (metadata.album) {
writer.setFrame('TALB', metadata.album); tags.album = metadata.album;
} }
const coverBuffer = await getCoverBuffer(metadata.imageSrc ?? '');
if (coverBuffer) { if (coverBuffer) {
writer.setFrame('APIC', { tags.image = {
type: 3, mime: 'image/png',
data: coverBuffer, type: {
description: '', id: TagConstants.AttachedPicture.PictureType.FRONT_COVER,
}); },
description: 'thumbnail',
imageBuffer: coverBuffer,
};
} }
if (isEnabled('lyrics-genius')) { if (isEnabled('lyrics-genius')) {
const lyrics = await fetchFromGenius(metadata); const lyrics = await fetchFromGenius(metadata);
if (lyrics) { if (lyrics) {
writer.setFrame('USLT', { tags.unsynchronisedLyrics = {
description: '', language: '',
lyrics, text: lyrics,
}); };
} }
} }
if (metadata.trackId) { if (metadata.trackId) {
writer.setFrame('TRCK', metadata.trackId); tags.trackNumber = metadata.trackId;
} }
writer.addTag(); return NodeID3.write(tags, buffer);
return Buffer.from(writer.arrayBuffer); } catch (error: unknown) {
} catch (error) { sendError(error as Error, `${metadata.artist} - ${metadata.title}`);
sendError(error, `${metadata.artist} - ${metadata.title}`); return null;
} }
} }
async function downloadPlaylist(givenUrl) { export async function downloadPlaylist(givenUrl?: string | URL) {
try { try {
givenUrl = new URL(givenUrl); givenUrl = new URL(givenUrl ?? '');
} catch { } catch {
givenUrl = undefined; return;
} }
const playlistId const playlistId
@ -366,18 +378,18 @@ async function downloadPlaylist(givenUrl) {
return; return;
} }
const sendFeedback = (message) => sendFeedback_(win, message); const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
console.log(`trying to get playlist ID: '${playlistId}'`); console.log(`trying to get playlist ID: '${playlistId}'`);
sendFeedback('Getting playlist info…'); sendFeedback('Getting playlist info…');
let playlist; let playlist: ytpl.Result;
try { try {
playlist = await ytpl(playlistId, { playlist = await ytpl(playlistId, {
limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY, limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY,
}); });
} catch (error) { } catch (error: unknown) {
sendError( 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; return;
} }
@ -432,8 +444,8 @@ async function downloadPlaylist(givenUrl) {
const progressStep = 1 / playlist.items.length; const progressStep = 1 / playlist.items.length;
const increaseProgress = (itemPercentage) => { const increaseProgress = (itemPercentage: number) => {
const currentProgress = (counter - 1) / playlist.items.length; const currentProgress = (counter - 1) / (playlist.items.length ?? 1);
const newProgress = currentProgress + (progressStep * itemPercentage); const newProgress = currentProgress + (progressStep * itemPercentage);
win.setProgressBar(newProgress); win.setProgressBar(newProgress);
}; };
@ -445,11 +457,11 @@ async function downloadPlaylist(givenUrl) {
await downloadSong( await downloadSong(
song.url, song.url,
playlistFolder, playlistFolder,
trackId, trackId?.toString(),
increaseProgress, increaseProgress,
).catch((error) => ).catch((error) =>
sendError( 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); setBadge(playlist.items.length - counter);
counter++; counter++;
} }
} catch (error) { } catch (error: unknown) {
sendError(error); sendError(error as Error);
} finally { } finally {
win.setProgressBar(-1); // Close progress bar win.setProgressBar(-1); // Close progress bar
setBadge(0); // Close badge counter 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(); const releaseFFmpegMutex = await ffmpegMutex.acquire();
try { try {
@ -481,16 +493,16 @@ async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
...ffmpegArgs, ...ffmpegArgs,
filePath, filePath,
); );
} catch (error) { } catch (error: unknown) {
sendError(error); sendError(error as Error);
} finally { } finally {
releaseFFmpegMutex(); releaseFFmpegMutex();
} }
} }
function getFFmpegMetadataArgs(metadata) { function getFFmpegMetadataArgs(metadata: CustomSongInfo) {
if (!metadata) { if (!metadata) {
return; return [];
} }
return [ return [
@ -504,7 +516,7 @@ function getFFmpegMetadataArgs(metadata) {
// Playlist radio modifier needs to be cut from playlist ID // Playlist radio modifier needs to be cut from playlist ID
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL'; const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
const getPlaylistID = (aURL) => { const getPlaylistID = (aURL: URL) => {
const result const result
= aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist'); = aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) { if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
@ -514,7 +526,7 @@ const getPlaylistID = (aURL) => {
return result; return result;
}; };
const getVideoId = (url) => { const getVideoId = (url: URL | string): string | null => {
if (typeof url === 'string') { if (typeof url === 'string') {
url = new URL(url); url = new URL(url);
} }
@ -522,18 +534,21 @@ const getVideoId = (url) => {
return url.searchParams.get('v'); return url.searchParams.get('v');
}; };
const getMetadata = (info) => ({ const getMetadata = (info: TrackInfo): CustomSongInfo => ({
id: info.basic_info.id, videoId: info.basic_info.id!,
title: cleanupName(info.basic_info.title), title: cleanupName(info.basic_info.title!),
artist: cleanupName(info.basic_info.author), artist: cleanupName(info.basic_info.author!),
album: info.player_overlays?.browser_media_session?.album?.text, // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
image: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))?.url, 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 // This is used to bypass age restrictions
const getAndroidTvInfo = async (id) => { const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
const innertube = await Innertube.create({ const innertube = await Innertube.create({
clientType: ClientType.TV_EMBEDDED, client_type: ClientType.TV_EMBEDDED,
generate_session_locally: true, generate_session_locally: true,
retrieve_player: 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'); import defaultConfig from '../../config/defaults';
const { getSongMenu } = require('../../providers/dom-elements'); import { getSongMenu } from '../../providers/dom-elements';
const { ElementFromFile, templatePath } = require('../utils'); import { ElementFromFile, templatePath } from '../utils';
import { getSongInfo } from '../../providers/song-info-front';
let menu = null; let menu: Element | null = null;
let progress = null; let progress: Element | null = null;
const downloadButton = ElementFromFile( const downloadButton = ElementFromFile(
templatePath(__dirname, 'download.html'), templatePath(__dirname, 'download.html'),
); );
@ -24,7 +25,7 @@ const menuObserver = new MutationObserver(() => {
return; 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) { if (!menuUrl?.includes('watch?') && doneFirstLoad) {
return; return;
} }
@ -42,7 +43,7 @@ const menuObserver = new MutationObserver(() => {
// TODO: re-enable once contextIsolation is set to true // TODO: re-enable once contextIsolation is set to true
// contextBridge.exposeInMainWorld("downloader", { // contextBridge.exposeInMainWorld("downloader", {
// download: () => { // download: () => {
global.download = () => { export const download = () => {
let videoUrl = getSongMenu() let videoUrl = getSongMenu()
// Selector of first button which is always "Start Radio" // Selector of first button which is always "Start Radio"
?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint') ?.querySelector('ytmusic-menu-navigation-item-renderer[tabindex="0"] #navigation-endpoint')
@ -57,21 +58,21 @@ global.download = () => {
return; return;
} }
} else { } else {
videoUrl = global.songInfo.url || window.location.href; videoUrl = getSongInfo().url || window.location.href;
} }
ipcRenderer.send('download-song', videoUrl); ipcRenderer.send('download-song', videoUrl);
}; };
module.exports = () => { export default () => {
document.addEventListener('apiLoaded', () => { document.addEventListener('apiLoaded', () => {
menuObserver.observe(document.querySelector('ytmusic-popup-container'), { menuObserver.observe(document.querySelector('ytmusic-popup-container')!, {
childList: true, childList: true,
subtree: true, subtree: true,
}); });
}, { once: true, passive: true }); }, { once: true, passive: true });
ipcRenderer.on('downloader-feedback', (_, feedback) => { ipcRenderer.on('downloader-feedback', (_, feedback: string) => {
if (progress) { if (progress) {
progress.innerHTML = feedback || 'Download'; progress.innerHTML = feedback || 'Download';
} else { } else {

View File

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

View File

@ -1,14 +1,14 @@
const { app } = require('electron'); import { app, BrowserWindow } from 'electron';
const is = require('electron-is'); import is from 'electron-is';
module.exports.getFolder = (customFolder) => customFolder || app.getPath('downloads'); export const getFolder = (customFolder: string) => customFolder || app.getPath('downloads');
module.exports.defaultMenuDownloadLabel = 'Download playlist'; export const defaultMenuDownloadLabel = 'Download playlist';
module.exports.sendFeedback = (win, message) => { export const sendFeedback = (win: BrowserWindow, message?: unknown) => {
win.webContents.send('downloader-feedback', message); win.webContents.send('downloader-feedback', message);
}; };
module.exports.cropMaxWidth = (image) => { export const cropMaxWidth = (image: Electron.NativeImage) => {
const imageSize = image.getSize(); const imageSize = image.getSize();
// Standart youtube artwork width with margins from both sides is 280 + 720 + 280 // Standart youtube artwork width with margins from both sides is 280 + 720 + 280
if (imageSize.width === 1280 && imageSize.height === 720) { if (imageSize.width === 1280 && imageSize.height === 720) {
@ -24,7 +24,7 @@ module.exports.cropMaxWidth = (image) => {
}; };
// Presets for FFmpeg // Presets for FFmpeg
module.exports.presets = { export const presets = {
'None (defaults to mp3)': undefined, 'None (defaults to mp3)': undefined,
'opus': { 'opus': {
extension: 'opus', extension: 'opus',
@ -32,7 +32,7 @@ module.exports.presets = {
}, },
}; };
module.exports.setBadge = (n) => { export const setBadge = (n: number) => {
if (is.linux() || is.macOS()) { if (is.linux() || is.macOS()) {
app.setBadgeCount(n); 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'); import { BrowserWindow, net, shell } from 'electron';
const md5 = require('md5'); import md5 from 'md5';
const { setOptions } = require('../../config/plugins'); import { setOptions } from '../../config/plugins';
const registerCallback = require('../../providers/song-info'); import registerCallback, { SongInfo } from '../../providers/song-info';
const defaultConfig = require('../../config/defaults'); 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 // Creates the body for in the post request
const formData = new URLSearchParams(); const formData = new URLSearchParams();
for (const key in parameters) { for (const key in parameters) {
formData.append(key, parameters[key]); formData.append(key, String(parameters[key]));
} }
return formData; return formData;
}; };
const createQueryString = (parameters, apiSignature) => { const createQueryString = (parameters: Record<string, unknown>, apiSignature: string) => {
// Creates a querystring // Creates a querystring
const queryData = []; const queryData = [];
parameters.api_sig = apiSignature; parameters.api_sig = apiSignature;
for (const key in parameters) { for (const key in parameters) {
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(parameters[key])}`); queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(parameters[key]))}`);
} }
return '?' + queryData.join('&'); 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 // This function creates the api signature, see: https://www.last.fm/api/authspec
const keys = []; const keys = [];
for (const key in parameters) { for (const key in parameters) {
@ -40,7 +49,7 @@ const createApiSig = (parameters, secret) => {
continue; continue;
} }
sig += `${key}${parameters[key]}`; sig += `${key}${String(parameters[key])}`;
} }
sig += secret; sig += secret;
@ -48,7 +57,7 @@ const createApiSig = (parameters, secret) => {
return sig; 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 // Creates and stores the auth token
const data = { const data = {
method: 'auth.gettoken', method: 'auth.gettoken',
@ -56,20 +65,18 @@ const createToken = async ({ apiKey, apiRoot, secret }) => {
format: 'json', format: 'json',
}; };
const apiSigature = createApiSig(data, secret); const apiSigature = createApiSig(data, secret);
let response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`); const response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`);
response = await response.json(); const json = await response.json() as Record<string, string>;
return response?.token; return json?.token;
}; };
const authenticate = async (config) => { const authenticateAndGetToken = async (config: LastFMOptions) => {
// Asks the user for authentication // Asks the user for authentication
config.token = await createToken(config); await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
setOptions('last-fm', config); return await createToken(config);
shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
return config;
}; };
const getAndSetSessionKey = async (config) => { const getAndSetSessionKey = async (config: LastFMOptions) => {
// Get and store the session key // Get and store the session key
const data = { const data = {
api_key: config.api_key, api_key: config.api_key,
@ -78,18 +85,25 @@ const getAndSetSessionKey = async (config) => {
token: config.token, token: config.token,
}; };
const apiSignature = createApiSig(data, config.secret); const apiSignature = createApiSig(data, config.secret);
let res = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`); const response = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`);
res = await res.json(); const json = await response.json() as {
if (res.error) { error?: string,
await authenticate(config); session?: {
key: string,
} }
};
config.session_key = res?.session?.key; if (json.error) {
config.token = await authenticateAndGetToken(config);
setOptions('last-fm', config); setOptions('last-fm', config);
}
if (json.session) {
config.session_key = json?.session?.key;
setOptions('last-fm', config);
}
return 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 // This sends a post request to the api, and adds the common data
if (!config.session_key) { if (!config.session_key) {
await getAndSetSessionKey(config); await getAndSetSessionKey(config);
@ -101,6 +115,7 @@ const postSongDataToAPI = async (songInfo, config, data) => {
artist: songInfo.artist, artist: songInfo.artist,
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video ...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
api_key: config.api_key, api_key: config.api_key,
api_sig: '',
sk: config.session_key, sk: config.session_key,
format: 'json', format: 'json',
...data, ...data,
@ -108,26 +123,32 @@ const postSongDataToAPI = async (songInfo, config, data) => {
postData.api_sig = createApiSig(postData, config.secret); postData.api_sig = createApiSig(postData, config.secret);
net.fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: createFormData(postData) }) net.fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: createFormData(postData) })
.catch((error) => { .catch(async (error: {
if (error.response.data.error === 9) { response?: {
data?: {
error: number,
}
}
}) => {
if (error?.response?.data?.error === 9) {
// Session key is invalid, so remove it from the config and reauthenticate // Session key is invalid, so remove it from the config and reauthenticate
config.session_key = undefined; config.session_key = undefined;
config.token = await authenticateAndGetToken(config);
setOptions('last-fm', 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 // This adds one scrobbled song to last.fm
const data = { const data = {
method: 'track.scrobble', method: 'track.scrobble',
timestamp: Math.trunc((Date.now() - songInfo.elapsedSeconds) / 1000), timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
}; };
postSongDataToAPI(songInfo, config, data); postSongDataToAPI(songInfo, config, data);
}; };
const setNowPlaying = (songInfo, config) => { const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
// This sets the now playing status in last.fm // This sets the now playing status in last.fm
const data = { const data = {
method: 'track.updateNowPlaying', method: 'track.updateNowPlaying',
@ -136,9 +157,9 @@ const setNowPlaying = (songInfo, config) => {
}; };
// This will store the timeout that will trigger addScrobble // 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) { if (!config.api_root) {
// Settings are not present, creating them with the default values // Settings are not present, creating them with the default values
config = defaultConfig.plugins['last-fm']; config = defaultConfig.plugins['last-fm'];
@ -156,11 +177,11 @@ const lastfm = async (_win, config) => {
clearTimeout(scrobbleTimer); clearTimeout(scrobbleTimer);
if (!songInfo.isPaused) { if (!songInfo.isPaused) {
setNowPlaying(songInfo, config); 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); 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 // Scrobble still needs to happen
const timeToWait = (scrobbleTime - songInfo.elapsedSeconds) * 1000; const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config); 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'); import { BrowserWindow, ipcMain, net } from 'electron';
const is = require('electron-is'); import is from 'electron-is';
const { convert } = require('html-to-text'); import { convert } from 'html-to-text';
const { cleanupName } = require('../../providers/song-info'); import { GetGeniusLyric } from './types';
const { injectCSS } = require('../utils');
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; const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
let revRomanized = false; 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) { if (options.romanizedLyrics) {
revRomanized = true; revRomanized = true;
} }
injectCSS(win.webContents, join(__dirname, 'style.css')); injectCSS(win.webContents, join(__dirname, 'style.css'));
ipcMain.on('search-genius-lyrics', async (event, extractedSongInfo) => { ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: string) => {
const metadata = JSON.parse(extractedSongInfo); const metadata = JSON.parse(extractedSongInfo) as SongInfo;
event.returnValue = await fetchFromGenius(metadata); return await fetchFromGenius(metadata);
}); });
}; };
const toggleRomanized = () => { export const toggleRomanized = () => {
revRomanized = !revRomanized; revRomanized = !revRomanized;
}; };
const fetchFromGenius = async (metadata) => { export const fetchFromGenius = async (metadata: SongInfo) => {
const songTitle = `${cleanupName(metadata.title)}`; const songTitle = `${cleanupName(metadata.title)}`;
const songArtist = `${cleanupName(metadata.artist)}`; 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 /* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal
Genius Lyrics behavior is observed. 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 /* 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. 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`); lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
} }
@ -58,7 +65,7 @@ const fetchFromGenius = async (metadata) => {
* @param {*} queryString * @param {*} queryString
* @returns The lyrics of the first song found using the Genius-Lyrics API * @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( const response = await net.fetch(
`https://genius.com/api/search/multi?per_page=5&q=${encodeURIComponent(queryString)}`, `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. /* Fetch the first URL with the api, giving a collection of song results.
Pick the first song, parsing the json given by the API. Pick the first song, parsing the json given by the API.
*/ */
const info = await response.json(); const info = await response.json() as GetGeniusLyric;
let url = ''; const url = info
try { .response
url = info.response.sections.find((section) => section.type === 'song') .sections
.hits[0].result.url; .find((section) => section.type === 'song')?.hits[0].result.url;
} catch {
if (url) {
return await getLyrics(url);
} else {
return null; return null;
} }
const lyrics = await getLyrics(url);
return lyrics;
}; };
/** /**
@ -87,7 +94,7 @@ const getLyricsList = async (queryString) => {
* @param {*} url * @param {*} url
* @returns The lyrics of the song URL provided, null if none * @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); const response = await net.fetch(url);
if (!response.ok) { if (!response.ok) {
return null; 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'); import { ipcRenderer } from 'electron';
const is = require('electron-is'); import is from 'electron-is';
module.exports = () => { export default () => {
ipcRenderer.on('update-song-info', (_, extractedSongInfo) => setTimeout(() => { ipcRenderer.on('update-song-info', (_, extractedSongInfo: string) => setTimeout(async () => {
const tabList = document.querySelectorAll('tp-yt-paper-tab'); const tabList = document.querySelectorAll('tp-yt-paper-tab');
const tabs = { const tabs = {
upNext: tabList[0], upNext: tabList[0],
@ -17,10 +17,10 @@ module.exports = () => {
let hasLyrics = true; let hasLyrics = true;
const lyrics = ipcRenderer.sendSync( const lyrics = await ipcRenderer.invoke(
'search-genius-lyrics', 'search-genius-lyrics',
extractedSongInfo, extractedSongInfo,
); ) as string;
if (!lyrics) { if (!lyrics) {
// Delete previous lyrics if tab is open and couldn't get new lyrics // Delete previous lyrics if tab is open and couldn't get new lyrics
checkLyricsContainer(() => { checkLyricsContainer(() => {
@ -40,8 +40,9 @@ module.exports = () => {
checkLyricsContainer(); checkLyricsContainer();
tabs.lyrics.addEventListener('click', () => { const lyricsTabHandler = () => {
const tabContainer = document.querySelector('ytmusic-tab-renderer'); const tabContainer = document.querySelector('ytmusic-tab-renderer');
if (tabContainer) {
const observer = new MutationObserver((_, observer) => { const observer = new MutationObserver((_, observer) => {
checkLyricsContainer(() => observer.disconnect()); checkLyricsContainer(() => observer.disconnect());
}); });
@ -50,7 +51,10 @@ module.exports = () => {
childList: true, childList: true,
subtree: true, subtree: true,
}); });
}); }
};
tabs.lyrics.addEventListener('click', lyricsTabHandler);
function checkLyricsContainer(callback = () => { 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"> lyricsContainer.innerHTML = `<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
${ ${
hasLyrics hasLyrics
@ -74,15 +78,20 @@ module.exports = () => {
</div> </div>
<yt-formatted-string class="footer style-scope ytmusic-description-shelf-renderer" style="align-self: baseline"></yt-formatted-string>`; <yt-formatted-string class="footer style-scope ytmusic-description-shelf-renderer" style="align-self: baseline"></yt-formatted-string>`;
if (hasLyrics) { if (hasLyrics) {
lyricsContainer.querySelector('.footer').textContent = 'Source: Genius'; const footer = lyricsContainer.querySelector('.footer');
if (footer) {
footer.textContent = 'Source: Genius';
enableLyricsTab(); enableLyricsTab();
} }
} }
}
function setTabsOnclick(callback) { const defaultHandler = () => {};
function setTabsOnclick(callback: EventListenerOrEventListenerObject | undefined) {
for (const tab of [tabs.upNext, tabs.discover]) { for (const tab of [tabs.upNext, tabs.discover]) {
if (tab) { 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.duration = secToMilisec(songInfo.songDuration);
data.progress = secToMilisec(songInfo.elapsedSeconds); data.progress = secToMilisec(songInfo.elapsedSeconds ?? 0);
data.cover = songInfo.imageSrc ?? ''; data.cover = songInfo.imageSrc ?? '';
data.cover_url = songInfo.imageSrc ?? ''; data.cover_url = songInfo.imageSrc ?? '';
data.album_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'; import { GetState } from '../types/datahost-get-state';
let songInfo: SongInfo = {} as SongInfo; 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.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) => { ipcRenderer.on('update-song-info', async (_, extractedSongInfo: string) => {
songInfo = JSON.parse(extractedSongInfo) as SongInfo; songInfo = JSON.parse(extractedSongInfo) as SongInfo;

View File

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

View File

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