diff --git a/config/defaults.ts b/config/defaults.ts index c454863a..a09e916f 100644 --- a/config/defaults.ts +++ b/config/defaults.ts @@ -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 diff --git a/package-lock.json b/package-lock.json index 8bd2a261..51f51d3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6fe08c21..3c2c0ee3 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/plugins/discord/back.ts b/plugins/discord/back.ts index eb9ce125..f0970c3a 100644 --- a/plugins/discord/back.ts +++ b/plugins/discord/back.ts @@ -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); diff --git a/plugins/downloader/back.js b/plugins/downloader/back.ts similarity index 59% rename from plugins/downloader/back.js rename to plugins/downloader/back.ts index 9144f83c..f30f532a 100644 --- a/plugins/downloader/back.js +++ b/plugins/downloader/back.ts @@ -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, + 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 => { const innertube = await Innertube.create({ - clientType: ClientType.TV_EMBEDDED, + client_type: ClientType.TV_EMBEDDED, generate_session_locally: true, retrieve_player: true, }); diff --git a/plugins/downloader/config.js b/plugins/downloader/config.js deleted file mode 100644 index 8ea738d8..00000000 --- a/plugins/downloader/config.js +++ /dev/null @@ -1,4 +0,0 @@ -const { PluginConfig } = require('../../config/dynamic'); - -const config = new PluginConfig('downloader'); -module.exports = { ...config }; diff --git a/plugins/downloader/config.ts b/plugins/downloader/config.ts new file mode 100644 index 00000000..ec22de5a --- /dev/null +++ b/plugins/downloader/config.ts @@ -0,0 +1,4 @@ +import { PluginConfig } from '../../config/dynamic'; + +const config = new PluginConfig('downloader'); +export default { ...config } as PluginConfig<'downloader'>; diff --git a/plugins/downloader/front.js b/plugins/downloader/front.ts similarity index 69% rename from plugins/downloader/front.js rename to plugins/downloader/front.ts index ef486a24..b53f5dfc 100644 --- a/plugins/downloader/front.js +++ b/plugins/downloader/front.ts @@ -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 { diff --git a/plugins/downloader/menu.js b/plugins/downloader/menu.ts similarity index 76% rename from plugins/downloader/menu.js rename to plugins/downloader/menu.ts index 57953fe9..be5f8430 100644 --- a/plugins/downloader/menu.js +++ b/plugins/downloader/menu.ts @@ -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(), diff --git a/plugins/downloader/utils.js b/plugins/downloader/utils.ts similarity index 57% rename from plugins/downloader/utils.js rename to plugins/downloader/utils.ts index 2b311d60..7de1f096 100644 --- a/plugins/downloader/utils.js +++ b/plugins/downloader/utils.ts @@ -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); } diff --git a/plugins/in-app-menu/back.js b/plugins/in-app-menu/back.js deleted file mode 100644 index 9b5e4577..00000000 --- a/plugins/in-app-menu/back.js +++ /dev/null @@ -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'); - }); - }); -}; diff --git a/plugins/in-app-menu/back.ts b/plugins/in-app-menu/back.ts new file mode 100644 index 00000000..3fd13edf --- /dev/null +++ b/plugins/in-app-menu/back.ts @@ -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'); + }); + }); +}; diff --git a/plugins/in-app-menu/custom-electron-titlebar.d.ts b/plugins/in-app-menu/custom-electron-titlebar.d.ts new file mode 100644 index 00000000..f724278d --- /dev/null +++ b/plugins/in-app-menu/custom-electron-titlebar.d.ts @@ -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; +} diff --git a/plugins/in-app-menu/front.js b/plugins/in-app-menu/front.js deleted file mode 100644 index 83d8fe38..00000000 --- a/plugins/in-app-menu/front.js +++ /dev/null @@ -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'; -} diff --git a/plugins/in-app-menu/front.ts b/plugins/in-app-menu/front.ts new file mode 100644 index 00000000..61c09387 --- /dev/null +++ b/plugins/in-app-menu/front.ts @@ -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'; +} diff --git a/plugins/last-fm/back.js b/plugins/last-fm/back.ts similarity index 53% rename from plugins/last-fm/back.js rename to plugins/last-fm/back.ts index 7593f1c1..2a83d9ee 100644 --- a/plugins/last-fm/back.js +++ b/plugins/last-fm/back.ts @@ -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) => { // 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, 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, 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; + 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); } } diff --git a/plugins/lyrics-genius/back.js b/plugins/lyrics-genius/back.ts similarity index 65% rename from plugins/lyrics-genius/back.js rename to plugins/lyrics-genius/back.ts index 1913c193..b443b2fb 100644 --- a/plugins/lyrics-genius/back.js +++ b/plugins/lyrics-genius/back.ts @@ -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 => { 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 => { 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; diff --git a/plugins/lyrics-genius/front.js b/plugins/lyrics-genius/front.ts similarity index 63% rename from plugins/lyrics-genius/front.js rename to plugins/lyrics-genius/front.ts index b7d0bf7b..c87daf23 100644 --- a/plugins/lyrics-genius/front.js +++ b/plugins/lyrics-genius/front.ts @@ -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 = `
${ hasLyrics @@ -74,15 +78,20 @@ module.exports = () => {
`; 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); } } } diff --git a/plugins/lyrics-genius/menu.js b/plugins/lyrics-genius/menu.js deleted file mode 100644 index 1c771cbe..00000000 --- a/plugins/lyrics-genius/menu.js +++ /dev/null @@ -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(); - }, - }, -]; diff --git a/plugins/lyrics-genius/menu.ts b/plugins/lyrics-genius/menu.ts new file mode 100644 index 00000000..6dc6cd70 --- /dev/null +++ b/plugins/lyrics-genius/menu.ts @@ -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(); + }, + }, +]; diff --git a/plugins/lyrics-genius/types.ts b/plugins/lyrics-genius/types.ts new file mode 100644 index 00000000..fac46ada --- /dev/null +++ b/plugins/lyrics-genius/types.ts @@ -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; +} diff --git a/plugins/tuna-obs/back.ts b/plugins/tuna-obs/back.ts index 8df6fd98..76c26997 100644 --- a/plugins/tuna-obs/back.ts +++ b/plugins/tuna-obs/back.ts @@ -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 ?? ''; diff --git a/providers/song-info-front.ts b/providers/song-info-front.ts index 9e51aa20..602d6d49 100644 --- a/providers/song-info-front.ts +++ b/providers/song-info-front.ts @@ -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 $ = (s: string): E => document.querySelector(s) as E; -const $$ = (s: string): E[] => [...document.querySelectorAll(s)!] as E[]; +const $$ = (s: string): E[] => Array.from(document.querySelectorAll(s)); ipcRenderer.on('update-song-info', async (_, extractedSongInfo: string) => { songInfo = JSON.parse(extractedSongInfo) as SongInfo; diff --git a/providers/song-info.ts b/providers/song-info.ts index 486daef9..4ffc1110 100644 --- a/providers/song-info.ts +++ b/providers/song-info.ts @@ -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 diff --git a/tsconfig.json b/tsconfig.json index d4d53dcd..18fce570 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ESNext", - "lib": ["dom"], + "lib": ["dom", "es2022"], "module": "CommonJS", "allowSyntheticDefaultImports": true, "esModuleInterop": true,