mirror of
https://github.com/th-ch/youtube-music.git
synced 2026-01-09 17:51:46 +00:00
feat: migration to TypeScript part 3
Co-authored-by: Su-Yong <simssy2205@gmail.com>
This commit is contained in:
@ -3,6 +3,39 @@ export interface WindowSizeConfig {
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface DefaultConfig {
|
||||
'window-size': {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
'window-maximized': boolean;
|
||||
'window-position': {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
url: string;
|
||||
options: {
|
||||
tray: boolean;
|
||||
appVisible: boolean;
|
||||
autoUpdates: boolean;
|
||||
alwaysOnTop: boolean;
|
||||
hideMenu: boolean;
|
||||
hideMenuWarned: boolean;
|
||||
startAtLogin: boolean;
|
||||
disableHardwareAcceleration: boolean;
|
||||
removeUpgradeButton: boolean;
|
||||
restartOnConfigChanges: boolean;
|
||||
trayClickPlayPause: boolean;
|
||||
autoResetAppCache: boolean;
|
||||
resumeOnStart: boolean;
|
||||
likeButtons: string;
|
||||
proxy: string;
|
||||
startingPage: string;
|
||||
overrideUserAgent: boolean;
|
||||
themes: string[];
|
||||
}
|
||||
}
|
||||
|
||||
const defaultConfig = {
|
||||
'window-size': {
|
||||
width: 1100,
|
||||
@ -54,15 +87,22 @@ const defaultConfig = {
|
||||
'downloader': {
|
||||
enabled: false,
|
||||
ffmpegArgs: [], // E.g. ["-b:a", "192k"] for an audio bitrate of 192kb/s
|
||||
downloadFolder: undefined, // Custom download folder (absolute path)
|
||||
downloadFolder: undefined as string | undefined, // Custom download folder (absolute path)
|
||||
preset: 'mp3',
|
||||
skipExisting: false,
|
||||
playlistMaxItems: undefined as number | undefined,
|
||||
},
|
||||
'last-fm': {
|
||||
enabled: false,
|
||||
token: undefined as string | undefined, // Token used for authentication
|
||||
session_key: undefined as string | undefined, // Session key used for scrobbling
|
||||
api_root: 'http://ws.audioscrobbler.com/2.0/',
|
||||
api_key: '04d76faaac8726e60988e14c105d421a', // Api key registered by @semvis123
|
||||
secret: 'a5d2a36fdf64819290f6982481eaffa2',
|
||||
},
|
||||
'lyric-genius': {
|
||||
romanizedLyrics: false,
|
||||
},
|
||||
'discord': {
|
||||
enabled: false,
|
||||
autoReconnect: true, // If enabled, will try to reconnect to discord every 5 seconds after disconnecting or failing to connect
|
||||
|
||||
47
package-lock.json
generated
47
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -1,58 +1,64 @@
|
||||
const {
|
||||
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
createWriteStream,
|
||||
writeFileSync,
|
||||
} = require('node:fs');
|
||||
const { join } = require('node:path');
|
||||
} from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
const { randomBytes } = require('node:crypto');
|
||||
import { app, BrowserWindow, dialog, ipcMain } from 'electron';
|
||||
import { ClientType, Innertube, UniversalCache, Utils } from 'youtubei.js';
|
||||
import is from 'electron-is';
|
||||
import ytpl from 'ytpl';
|
||||
// REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
|
||||
import filenamify from 'filenamify';
|
||||
import { Mutex } from 'async-mutex';
|
||||
import { createFFmpeg } from '@ffmpeg/ffmpeg';
|
||||
|
||||
const { ipcMain, app, dialog } = require('electron');
|
||||
const is = require('electron-is');
|
||||
const { Innertube, UniversalCache, Utils, ClientType } = require('youtubei.js');
|
||||
const ytpl = require('ytpl'); // REPLACE with youtubei getplaylist https://github.com/LuanRT/YouTube.js#getplaylistid
|
||||
const filenamify = require('filenamify');
|
||||
const ID3Writer = require('browser-id3-writer');
|
||||
const { Mutex } = require('async-mutex');
|
||||
const ffmpeg = require('@ffmpeg/ffmpeg').createFFmpeg({
|
||||
import NodeID3, { TagConstants } from 'node-id3';
|
||||
|
||||
import PlayerErrorMessage from 'youtubei.js/dist/src/parser/classes/PlayerErrorMessage';
|
||||
import { FormatOptions } from 'youtubei.js/dist/src/types/FormatUtils';
|
||||
|
||||
import TrackInfo from 'youtubei.js/dist/src/parser/ytmusic/TrackInfo';
|
||||
|
||||
import { VideoInfo } from 'youtubei.js/dist/src/parser/youtube';
|
||||
|
||||
import { cropMaxWidth, getFolder, presets, sendFeedback as sendFeedback_, setBadge } from './utils';
|
||||
|
||||
import config from './config';
|
||||
|
||||
import { fetchFromGenius } from '../lyrics-genius/back';
|
||||
import { isEnabled } from '../../config/plugins';
|
||||
import { cleanupName, getImage, SongInfo } from '../../providers/song-info';
|
||||
import { injectCSS } from '../utils';
|
||||
import { cache } from '../../providers/decorators';
|
||||
import { GetPlayerResponse } from '../../types/get-player-response';
|
||||
|
||||
type CustomSongInfo = SongInfo & { trackId?: string };
|
||||
|
||||
const ffmpeg = createFFmpeg({
|
||||
log: false,
|
||||
logger() {
|
||||
}, // Console.log,
|
||||
progress() {
|
||||
}, // Console.log,
|
||||
});
|
||||
|
||||
const {
|
||||
presets,
|
||||
cropMaxWidth,
|
||||
getFolder,
|
||||
setBadge,
|
||||
sendFeedback: sendFeedback_,
|
||||
} = require('./utils');
|
||||
|
||||
const config = require('./config');
|
||||
|
||||
const { fetchFromGenius } = require('../lyrics-genius/back');
|
||||
const { isEnabled } = require('../../config/plugins');
|
||||
const { getImage, cleanupName } = require('../../providers/song-info');
|
||||
const { injectCSS } = require('../utils');
|
||||
const { cache } = require('../../providers/decorators');
|
||||
|
||||
const ffmpegMutex = new Mutex();
|
||||
|
||||
/** @type {Innertube} */
|
||||
let yt;
|
||||
let win;
|
||||
let playingUrl;
|
||||
let yt: Innertube;
|
||||
let win: BrowserWindow;
|
||||
let playingUrl: string;
|
||||
|
||||
const sendError = (error, source) => {
|
||||
const sendError = (error: Error, source?: string) => {
|
||||
win.setProgressBar(-1); // Close progress bar
|
||||
setBadge(0); // Close badge
|
||||
sendFeedback_(win); // Reset feedback
|
||||
|
||||
const songNameMessage = source ? `\nin ${source}` : '';
|
||||
const cause = error.cause ? `\n\n${error.cause.toString()}` : '';
|
||||
const cause = error.cause ? `\n\n${String(error.cause)}` : '';
|
||||
const message = `${error.toString()}${songNameMessage}${cause}`;
|
||||
|
||||
console.error(message);
|
||||
@ -65,7 +71,7 @@ const sendError = (error, source) => {
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = async (win_) => {
|
||||
export default async (win_: BrowserWindow) => {
|
||||
win = win_;
|
||||
injectCSS(win.webContents, join(__dirname, 'style.css'));
|
||||
|
||||
@ -73,52 +79,48 @@ module.exports = async (win_) => {
|
||||
cache: new UniversalCache(false),
|
||||
generate_session_locally: true,
|
||||
});
|
||||
ipcMain.on('download-song', (_, url) => downloadSong(url));
|
||||
ipcMain.on('video-src-changed', async (_, data) => {
|
||||
playingUrl
|
||||
= JSON.parse(data)?.microformat?.microformatDataRenderer?.urlCanonical;
|
||||
ipcMain.on('download-song', (_, url: string) => downloadSong(url));
|
||||
ipcMain.on('video-src-changed', (_, data: string) => {
|
||||
playingUrl = (JSON.parse(data) as GetPlayerResponse).microformat.microformatDataRenderer.urlCanonical;
|
||||
});
|
||||
ipcMain.on('download-playlist-request', async (_event, url) =>
|
||||
ipcMain.on('download-playlist-request', async (_event, url: string) =>
|
||||
downloadPlaylist(url),
|
||||
);
|
||||
};
|
||||
|
||||
module.exports.downloadSong = downloadSong;
|
||||
module.exports.downloadPlaylist = downloadPlaylist;
|
||||
|
||||
async function downloadSong(
|
||||
url,
|
||||
playlistFolder = undefined,
|
||||
trackId = undefined,
|
||||
increasePlaylistProgress = () => {
|
||||
export async function downloadSong(
|
||||
url: string,
|
||||
playlistFolder: string | undefined = undefined,
|
||||
trackId: string | undefined = undefined,
|
||||
increasePlaylistProgress: (value: number) => void = () => {
|
||||
},
|
||||
) {
|
||||
let resolvedName;
|
||||
try {
|
||||
await downloadSongUnsafe(
|
||||
url,
|
||||
(name) => resolvedName = name,
|
||||
(name: string) => resolvedName = name,
|
||||
playlistFolder,
|
||||
trackId,
|
||||
increasePlaylistProgress,
|
||||
);
|
||||
} catch (error) {
|
||||
sendError(error, resolvedName || url);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error, resolvedName || url);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadSongUnsafe(
|
||||
url,
|
||||
setName,
|
||||
playlistFolder = undefined,
|
||||
trackId = undefined,
|
||||
increasePlaylistProgress = () => {
|
||||
url: string,
|
||||
setName: (name: string) => void,
|
||||
playlistFolder: string | undefined = undefined,
|
||||
trackId: string | undefined = undefined,
|
||||
increasePlaylistProgress: (value: number) => void = () => {
|
||||
},
|
||||
) {
|
||||
const sendFeedback = (message, progress) => {
|
||||
const sendFeedback = (message: unknown, progress?: number) => {
|
||||
if (!playlistFolder) {
|
||||
sendFeedback_(win, message);
|
||||
if (!isNaN(progress)) {
|
||||
if (progress && !isNaN(progress)) {
|
||||
win.setProgressBar(progress);
|
||||
}
|
||||
}
|
||||
@ -127,7 +129,9 @@ async function downloadSongUnsafe(
|
||||
sendFeedback('Downloading...', 2);
|
||||
|
||||
const id = getVideoId(url);
|
||||
let info = await yt.music.getInfo(id);
|
||||
if (typeof id !== 'string') throw new Error('Video not found');
|
||||
|
||||
let info: TrackInfo | VideoInfo = await yt.music.getInfo(id);
|
||||
|
||||
if (!info) {
|
||||
throw new Error('Video not found');
|
||||
@ -164,19 +168,19 @@ async function downloadSongUnsafe(
|
||||
}
|
||||
|
||||
if (playabilityStatus.status === 'UNPLAYABLE') {
|
||||
/**
|
||||
* @typedef {import('youtubei.js/dist/src/parser/classes/PlayerErrorMessage').default} PlayerErrorMessage
|
||||
* @type {PlayerErrorMessage}
|
||||
*/
|
||||
const errorScreen = playabilityStatus.error_screen;
|
||||
const errorScreen = playabilityStatus.error_screen as PlayerErrorMessage | null;
|
||||
throw new Error(
|
||||
`[${playabilityStatus.status}] ${errorScreen.reason.text}: ${errorScreen.subreason.text}`,
|
||||
`[${playabilityStatus.status}] ${errorScreen?.reason.text}: ${errorScreen?.subreason.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const extension = presets[config.get('preset')]?.extension || 'mp3';
|
||||
const preset = config.get('preset') ?? 'mp3';
|
||||
let presetSetting: { extension: string; ffmpegArgs: string[] } | null = null;
|
||||
if (preset === 'opus') {
|
||||
presetSetting = presets[preset];
|
||||
}
|
||||
|
||||
const filename = filenamify(`${name}.${extension}`, {
|
||||
const filename = filenamify(`${name}.${presetSetting?.extension ?? 'mp3'}`, {
|
||||
replacement: '_',
|
||||
maxLength: 255,
|
||||
});
|
||||
@ -187,7 +191,7 @@ async function downloadSongUnsafe(
|
||||
return;
|
||||
}
|
||||
|
||||
const downloadOptions = {
|
||||
const downloadOptions: FormatOptions = {
|
||||
type: 'audio', // Audio, video or video+audio
|
||||
quality: 'best', // Best, bestefficiency, 144p, 240p, 480p, 720p and so on.
|
||||
format: 'any', // Media container format
|
||||
@ -197,7 +201,7 @@ async function downloadSongUnsafe(
|
||||
const stream = await info.download(downloadOptions);
|
||||
|
||||
console.info(
|
||||
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.id}]`,
|
||||
`Downloading ${metadata.artist} - ${metadata.title} [${metadata.videoId}]`,
|
||||
);
|
||||
|
||||
const iterableStream = Utils.streamToIterable(stream);
|
||||
@ -206,10 +210,10 @@ async function downloadSongUnsafe(
|
||||
mkdirSync(dir);
|
||||
}
|
||||
|
||||
if (presets[config.get('preset')]) {
|
||||
if (presetSetting) {
|
||||
const file = createWriteStream(filePath);
|
||||
let downloaded = 0;
|
||||
const total = format.content_length;
|
||||
const total: number = format.content_length ?? 1;
|
||||
|
||||
for await (const chunk of iterableStream) {
|
||||
downloaded += chunk.length;
|
||||
@ -223,18 +227,23 @@ async function downloadSongUnsafe(
|
||||
await ffmpegWriteTags(
|
||||
filePath,
|
||||
metadata,
|
||||
presets[config.get('preset')]?.ffmpegArgs,
|
||||
presetSetting.ffmpegArgs,
|
||||
);
|
||||
sendFeedback(null, -1);
|
||||
} else {
|
||||
const fileBuffer = await iterableStreamToMP3(
|
||||
iterableStream,
|
||||
metadata,
|
||||
format.content_length,
|
||||
format.content_length ?? 0,
|
||||
sendFeedback,
|
||||
increasePlaylistProgress,
|
||||
);
|
||||
writeFileSync(filePath, await writeID3(fileBuffer, metadata, sendFeedback));
|
||||
if (fileBuffer) {
|
||||
const buffer = await writeID3(Buffer.from(fileBuffer), metadata, sendFeedback);
|
||||
if (buffer) {
|
||||
writeFileSync(filePath, buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendFeedback(null, -1);
|
||||
@ -242,11 +251,11 @@ async function downloadSongUnsafe(
|
||||
}
|
||||
|
||||
async function iterableStreamToMP3(
|
||||
stream,
|
||||
metadata,
|
||||
contentLength,
|
||||
sendFeedback,
|
||||
increasePlaylistProgress = () => {
|
||||
stream: AsyncGenerator<Uint8Array, void>,
|
||||
metadata: CustomSongInfo,
|
||||
contentLength: number,
|
||||
sendFeedback: (str: string, value?: number) => void,
|
||||
increasePlaylistProgress: (value: number) => void = () => {
|
||||
},
|
||||
) {
|
||||
const chunks = [];
|
||||
@ -294,66 +303,69 @@ async function iterableStreamToMP3(
|
||||
sendFeedback('Saving…');
|
||||
|
||||
return ffmpeg.FS('readFile', `${safeVideoName}.mp3`);
|
||||
} catch (error) {
|
||||
sendError(error, safeVideoName);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error, safeVideoName);
|
||||
} finally {
|
||||
releaseFFmpegMutex();
|
||||
}
|
||||
}
|
||||
|
||||
const getCoverBuffer = cache(async (url) => {
|
||||
const getCoverBuffer = cache(async (url: string) => {
|
||||
const nativeImage = cropMaxWidth(await getImage(url));
|
||||
return nativeImage && !nativeImage.isEmpty() ? nativeImage.toPNG() : null;
|
||||
});
|
||||
|
||||
async function writeID3(buffer, metadata, sendFeedback) {
|
||||
async function writeID3(buffer: Buffer, metadata: CustomSongInfo, sendFeedback: (str: string, value?: number) => void) {
|
||||
try {
|
||||
sendFeedback('Writing ID3 tags...');
|
||||
|
||||
const coverBuffer = await getCoverBuffer(metadata.image);
|
||||
|
||||
const writer = new ID3Writer(buffer);
|
||||
const tags: NodeID3.Tags = {};
|
||||
|
||||
// Create the metadata tags
|
||||
writer.setFrame('TIT2', metadata.title).setFrame('TPE1', [metadata.artist]);
|
||||
tags.title = metadata.title;
|
||||
tags.artist = metadata.artist;
|
||||
|
||||
if (metadata.album) {
|
||||
writer.setFrame('TALB', metadata.album);
|
||||
tags.album = metadata.album;
|
||||
}
|
||||
|
||||
const coverBuffer = await getCoverBuffer(metadata.imageSrc ?? '');
|
||||
if (coverBuffer) {
|
||||
writer.setFrame('APIC', {
|
||||
type: 3,
|
||||
data: coverBuffer,
|
||||
description: '',
|
||||
});
|
||||
tags.image = {
|
||||
mime: 'image/png',
|
||||
type: {
|
||||
id: TagConstants.AttachedPicture.PictureType.FRONT_COVER,
|
||||
},
|
||||
description: 'thumbnail',
|
||||
imageBuffer: coverBuffer,
|
||||
};
|
||||
}
|
||||
|
||||
if (isEnabled('lyrics-genius')) {
|
||||
const lyrics = await fetchFromGenius(metadata);
|
||||
if (lyrics) {
|
||||
writer.setFrame('USLT', {
|
||||
description: '',
|
||||
lyrics,
|
||||
});
|
||||
tags.unsynchronisedLyrics = {
|
||||
language: '',
|
||||
text: lyrics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.trackId) {
|
||||
writer.setFrame('TRCK', metadata.trackId);
|
||||
tags.trackNumber = metadata.trackId;
|
||||
}
|
||||
|
||||
writer.addTag();
|
||||
return Buffer.from(writer.arrayBuffer);
|
||||
} catch (error) {
|
||||
sendError(error, `${metadata.artist} - ${metadata.title}`);
|
||||
return NodeID3.write(tags, buffer);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error, `${metadata.artist} - ${metadata.title}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadPlaylist(givenUrl) {
|
||||
export async function downloadPlaylist(givenUrl?: string | URL) {
|
||||
try {
|
||||
givenUrl = new URL(givenUrl);
|
||||
givenUrl = new URL(givenUrl ?? '');
|
||||
} catch {
|
||||
givenUrl = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const playlistId
|
||||
@ -366,18 +378,18 @@ async function downloadPlaylist(givenUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sendFeedback = (message) => sendFeedback_(win, message);
|
||||
const sendFeedback = (message?: unknown) => sendFeedback_(win, message);
|
||||
|
||||
console.log(`trying to get playlist ID: '${playlistId}'`);
|
||||
sendFeedback('Getting playlist info…');
|
||||
let playlist;
|
||||
let playlist: ytpl.Result;
|
||||
try {
|
||||
playlist = await ytpl(playlistId, {
|
||||
limit: config.get('playlistMaxItems') || Number.POSITIVE_INFINITY,
|
||||
});
|
||||
} catch (error) {
|
||||
} catch (error: unknown) {
|
||||
sendError(
|
||||
`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${error}`,
|
||||
Error(`Error getting playlist info: make sure it isn't a private or "Mixed for you" playlist\n\n${String(error)}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@ -432,8 +444,8 @@ async function downloadPlaylist(givenUrl) {
|
||||
|
||||
const progressStep = 1 / playlist.items.length;
|
||||
|
||||
const increaseProgress = (itemPercentage) => {
|
||||
const currentProgress = (counter - 1) / playlist.items.length;
|
||||
const increaseProgress = (itemPercentage: number) => {
|
||||
const currentProgress = (counter - 1) / (playlist.items.length ?? 1);
|
||||
const newProgress = currentProgress + (progressStep * itemPercentage);
|
||||
win.setProgressBar(newProgress);
|
||||
};
|
||||
@ -445,11 +457,11 @@ async function downloadPlaylist(givenUrl) {
|
||||
await downloadSong(
|
||||
song.url,
|
||||
playlistFolder,
|
||||
trackId,
|
||||
trackId?.toString(),
|
||||
increaseProgress,
|
||||
).catch((error) =>
|
||||
sendError(
|
||||
`Error downloading "${song.author.name} - ${song.title}":\n ${error}`,
|
||||
new Error(`Error downloading "${song.author.name} - ${song.title}":\n ${error}`)
|
||||
),
|
||||
);
|
||||
|
||||
@ -457,8 +469,8 @@ async function downloadPlaylist(givenUrl) {
|
||||
setBadge(playlist.items.length - counter);
|
||||
counter++;
|
||||
}
|
||||
} catch (error) {
|
||||
sendError(error);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error);
|
||||
} finally {
|
||||
win.setProgressBar(-1); // Close progress bar
|
||||
setBadge(0); // Close badge counter
|
||||
@ -466,7 +478,7 @@ async function downloadPlaylist(givenUrl) {
|
||||
}
|
||||
}
|
||||
|
||||
async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
|
||||
async function ffmpegWriteTags(filePath: string, metadata: CustomSongInfo, ffmpegArgs: string[] = []) {
|
||||
const releaseFFmpegMutex = await ffmpegMutex.acquire();
|
||||
|
||||
try {
|
||||
@ -481,16 +493,16 @@ async function ffmpegWriteTags(filePath, metadata, ffmpegArgs = []) {
|
||||
...ffmpegArgs,
|
||||
filePath,
|
||||
);
|
||||
} catch (error) {
|
||||
sendError(error);
|
||||
} catch (error: unknown) {
|
||||
sendError(error as Error);
|
||||
} finally {
|
||||
releaseFFmpegMutex();
|
||||
}
|
||||
}
|
||||
|
||||
function getFFmpegMetadataArgs(metadata) {
|
||||
function getFFmpegMetadataArgs(metadata: CustomSongInfo) {
|
||||
if (!metadata) {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
@ -504,7 +516,7 @@ function getFFmpegMetadataArgs(metadata) {
|
||||
// Playlist radio modifier needs to be cut from playlist ID
|
||||
const INVALID_PLAYLIST_MODIFIER = 'RDAMPL';
|
||||
|
||||
const getPlaylistID = (aURL) => {
|
||||
const getPlaylistID = (aURL: URL) => {
|
||||
const result
|
||||
= aURL?.searchParams.get('list') || aURL?.searchParams.get('playlist');
|
||||
if (result?.startsWith(INVALID_PLAYLIST_MODIFIER)) {
|
||||
@ -514,7 +526,7 @@ const getPlaylistID = (aURL) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const getVideoId = (url) => {
|
||||
const getVideoId = (url: URL | string): string | null => {
|
||||
if (typeof url === 'string') {
|
||||
url = new URL(url);
|
||||
}
|
||||
@ -522,18 +534,21 @@ const getVideoId = (url) => {
|
||||
return url.searchParams.get('v');
|
||||
};
|
||||
|
||||
const getMetadata = (info) => ({
|
||||
id: info.basic_info.id,
|
||||
title: cleanupName(info.basic_info.title),
|
||||
artist: cleanupName(info.basic_info.author),
|
||||
album: info.player_overlays?.browser_media_session?.album?.text,
|
||||
image: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))?.url,
|
||||
const getMetadata = (info: TrackInfo): CustomSongInfo => ({
|
||||
videoId: info.basic_info.id!,
|
||||
title: cleanupName(info.basic_info.title!),
|
||||
artist: cleanupName(info.basic_info.author!),
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-explicit-any
|
||||
album: (info.player_overlays?.browser_media_session as any)?.album?.text as string | undefined,
|
||||
imageSrc: info.basic_info.thumbnail?.find((t) => !t.url.endsWith('.webp'))?.url,
|
||||
views: info.basic_info.view_count!,
|
||||
songDuration: info.basic_info.duration!,
|
||||
});
|
||||
|
||||
// This is used to bypass age restrictions
|
||||
const getAndroidTvInfo = async (id) => {
|
||||
const getAndroidTvInfo = async (id: string): Promise<VideoInfo> => {
|
||||
const innertube = await Innertube.create({
|
||||
clientType: ClientType.TV_EMBEDDED,
|
||||
client_type: ClientType.TV_EMBEDDED,
|
||||
generate_session_locally: true,
|
||||
retrieve_player: true,
|
||||
});
|
||||
@ -1,4 +0,0 @@
|
||||
const { PluginConfig } = require('../../config/dynamic');
|
||||
|
||||
const config = new PluginConfig('downloader');
|
||||
module.exports = { ...config };
|
||||
4
plugins/downloader/config.ts
Normal file
4
plugins/downloader/config.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PluginConfig } from '../../config/dynamic';
|
||||
|
||||
const config = new PluginConfig('downloader');
|
||||
export default { ...config } as PluginConfig<'downloader'>;
|
||||
@ -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 {
|
||||
@ -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(),
|
||||
@ -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);
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
};
|
||||
27
plugins/in-app-menu/back.ts
Normal file
27
plugins/in-app-menu/back.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
};
|
||||
9
plugins/in-app-menu/custom-electron-titlebar.d.ts
vendored
Normal file
9
plugins/in-app-menu/custom-electron-titlebar.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
}
|
||||
96
plugins/in-app-menu/front.ts
Normal file
96
plugins/in-app-menu/front.ts
Normal 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';
|
||||
}
|
||||
@ -1,32 +1,41 @@
|
||||
const { shell, net } = require('electron');
|
||||
const md5 = require('md5');
|
||||
import { BrowserWindow, net, shell } from 'electron';
|
||||
import md5 from 'md5';
|
||||
|
||||
const { setOptions } = require('../../config/plugins');
|
||||
const registerCallback = require('../../providers/song-info');
|
||||
const defaultConfig = require('../../config/defaults');
|
||||
import { setOptions } from '../../config/plugins';
|
||||
import registerCallback, { SongInfo } from '../../providers/song-info';
|
||||
import defaultConfig from '../../config/defaults';
|
||||
import config from '../../config';
|
||||
|
||||
const createFormData = (parameters) => {
|
||||
const LastFMOptionsObj = config.get('plugins.last-fm');
|
||||
type LastFMOptions = typeof LastFMOptionsObj;
|
||||
|
||||
interface LastFmData {
|
||||
method: string,
|
||||
timestamp?: number,
|
||||
}
|
||||
|
||||
const createFormData = (parameters: Record<string, unknown>) => {
|
||||
// Creates the body for in the post request
|
||||
const formData = new URLSearchParams();
|
||||
for (const key in parameters) {
|
||||
formData.append(key, parameters[key]);
|
||||
formData.append(key, String(parameters[key]));
|
||||
}
|
||||
|
||||
return formData;
|
||||
};
|
||||
|
||||
const createQueryString = (parameters, apiSignature) => {
|
||||
const createQueryString = (parameters: Record<string, unknown>, apiSignature: string) => {
|
||||
// Creates a querystring
|
||||
const queryData = [];
|
||||
parameters.api_sig = apiSignature;
|
||||
for (const key in parameters) {
|
||||
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(parameters[key])}`);
|
||||
queryData.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(parameters[key]))}`);
|
||||
}
|
||||
|
||||
return '?' + queryData.join('&');
|
||||
};
|
||||
|
||||
const createApiSig = (parameters, secret) => {
|
||||
const createApiSig = (parameters: Record<string, unknown>, secret: string) => {
|
||||
// This function creates the api signature, see: https://www.last.fm/api/authspec
|
||||
const keys = [];
|
||||
for (const key in parameters) {
|
||||
@ -40,7 +49,7 @@ const createApiSig = (parameters, secret) => {
|
||||
continue;
|
||||
}
|
||||
|
||||
sig += `${key}${parameters[key]}`;
|
||||
sig += `${key}${String(parameters[key])}`;
|
||||
}
|
||||
|
||||
sig += secret;
|
||||
@ -48,7 +57,7 @@ const createApiSig = (parameters, secret) => {
|
||||
return sig;
|
||||
};
|
||||
|
||||
const createToken = async ({ apiKey, apiRoot, secret }) => {
|
||||
const createToken = async ({ api_key: apiKey, api_root: apiRoot, secret }: LastFMOptions) => {
|
||||
// Creates and stores the auth token
|
||||
const data = {
|
||||
method: 'auth.gettoken',
|
||||
@ -56,20 +65,18 @@ const createToken = async ({ apiKey, apiRoot, secret }) => {
|
||||
format: 'json',
|
||||
};
|
||||
const apiSigature = createApiSig(data, secret);
|
||||
let response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`);
|
||||
response = await response.json();
|
||||
return response?.token;
|
||||
const response = await net.fetch(`${apiRoot}${createQueryString(data, apiSigature)}`);
|
||||
const json = await response.json() as Record<string, string>;
|
||||
return json?.token;
|
||||
};
|
||||
|
||||
const authenticate = async (config) => {
|
||||
const authenticateAndGetToken = async (config: LastFMOptions) => {
|
||||
// Asks the user for authentication
|
||||
config.token = await createToken(config);
|
||||
setOptions('last-fm', config);
|
||||
shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
||||
return config;
|
||||
await shell.openExternal(`https://www.last.fm/api/auth/?api_key=${config.api_key}&token=${config.token}`);
|
||||
return await createToken(config);
|
||||
};
|
||||
|
||||
const getAndSetSessionKey = async (config) => {
|
||||
const getAndSetSessionKey = async (config: LastFMOptions) => {
|
||||
// Get and store the session key
|
||||
const data = {
|
||||
api_key: config.api_key,
|
||||
@ -78,18 +85,25 @@ const getAndSetSessionKey = async (config) => {
|
||||
token: config.token,
|
||||
};
|
||||
const apiSignature = createApiSig(data, config.secret);
|
||||
let res = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`);
|
||||
res = await res.json();
|
||||
if (res.error) {
|
||||
await authenticate(config);
|
||||
const response = await net.fetch(`${config.api_root}${createQueryString(data, apiSignature)}`);
|
||||
const json = await response.json() as {
|
||||
error?: string,
|
||||
session?: {
|
||||
key: string,
|
||||
}
|
||||
};
|
||||
if (json.error) {
|
||||
config.token = await authenticateAndGetToken(config);
|
||||
setOptions('last-fm', config);
|
||||
}
|
||||
if (json.session) {
|
||||
config.session_key = json?.session?.key;
|
||||
setOptions('last-fm', config);
|
||||
}
|
||||
|
||||
config.session_key = res?.session?.key;
|
||||
setOptions('last-fm', config);
|
||||
return config;
|
||||
};
|
||||
|
||||
const postSongDataToAPI = async (songInfo, config, data) => {
|
||||
const postSongDataToAPI = async (songInfo: SongInfo, config: LastFMOptions, data: LastFmData) => {
|
||||
// This sends a post request to the api, and adds the common data
|
||||
if (!config.session_key) {
|
||||
await getAndSetSessionKey(config);
|
||||
@ -101,6 +115,7 @@ const postSongDataToAPI = async (songInfo, config, data) => {
|
||||
artist: songInfo.artist,
|
||||
...(songInfo.album ? { album: songInfo.album } : undefined), // Will be undefined if current song is a video
|
||||
api_key: config.api_key,
|
||||
api_sig: '',
|
||||
sk: config.session_key,
|
||||
format: 'json',
|
||||
...data,
|
||||
@ -108,26 +123,32 @@ const postSongDataToAPI = async (songInfo, config, data) => {
|
||||
|
||||
postData.api_sig = createApiSig(postData, config.secret);
|
||||
net.fetch('https://ws.audioscrobbler.com/2.0/', { method: 'POST', body: createFormData(postData) })
|
||||
.catch((error) => {
|
||||
if (error.response.data.error === 9) {
|
||||
.catch(async (error: {
|
||||
response?: {
|
||||
data?: {
|
||||
error: number,
|
||||
}
|
||||
}
|
||||
}) => {
|
||||
if (error?.response?.data?.error === 9) {
|
||||
// Session key is invalid, so remove it from the config and reauthenticate
|
||||
config.session_key = undefined;
|
||||
config.token = await authenticateAndGetToken(config);
|
||||
setOptions('last-fm', config);
|
||||
authenticate(config);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const addScrobble = (songInfo, config) => {
|
||||
const addScrobble = (songInfo: SongInfo, config: LastFMOptions) => {
|
||||
// This adds one scrobbled song to last.fm
|
||||
const data = {
|
||||
method: 'track.scrobble',
|
||||
timestamp: Math.trunc((Date.now() - songInfo.elapsedSeconds) / 1000),
|
||||
timestamp: Math.trunc((Date.now() - (songInfo.elapsedSeconds ?? 0)) / 1000),
|
||||
};
|
||||
postSongDataToAPI(songInfo, config, data);
|
||||
};
|
||||
|
||||
const setNowPlaying = (songInfo, config) => {
|
||||
const setNowPlaying = (songInfo: SongInfo, config: LastFMOptions) => {
|
||||
// This sets the now playing status in last.fm
|
||||
const data = {
|
||||
method: 'track.updateNowPlaying',
|
||||
@ -136,9 +157,9 @@ const setNowPlaying = (songInfo, config) => {
|
||||
};
|
||||
|
||||
// This will store the timeout that will trigger addScrobble
|
||||
let scrobbleTimer;
|
||||
let scrobbleTimer: NodeJS.Timeout | undefined;
|
||||
|
||||
const lastfm = async (_win, config) => {
|
||||
const lastfm = async (_win: BrowserWindow, config: LastFMOptions) => {
|
||||
if (!config.api_root) {
|
||||
// Settings are not present, creating them with the default values
|
||||
config = defaultConfig.plugins['last-fm'];
|
||||
@ -156,11 +177,11 @@ const lastfm = async (_win, config) => {
|
||||
clearTimeout(scrobbleTimer);
|
||||
if (!songInfo.isPaused) {
|
||||
setNowPlaying(songInfo, config);
|
||||
// Scrobble when the song is half way through, or has passed the 4 minute mark
|
||||
// Scrobble when the song is halfway through, or has passed the 4-minute mark
|
||||
const scrobbleTime = Math.min(Math.ceil(songInfo.songDuration / 2), 4 * 60);
|
||||
if (scrobbleTime > songInfo.elapsedSeconds) {
|
||||
if (scrobbleTime > (songInfo.elapsedSeconds ?? 0)) {
|
||||
// Scrobble still needs to happen
|
||||
const timeToWait = (scrobbleTime - songInfo.elapsedSeconds) * 1000;
|
||||
const timeToWait = (scrobbleTime - (songInfo.elapsedSeconds ?? 0)) * 1000;
|
||||
scrobbleTimer = setTimeout(addScrobble, timeToWait, songInfo, config);
|
||||
}
|
||||
}
|
||||
@ -1,36 +1,43 @@
|
||||
const { join } = require('node:path');
|
||||
import { join } from 'node:path';
|
||||
|
||||
const { ipcMain, net } = require('electron');
|
||||
const is = require('electron-is');
|
||||
const { convert } = require('html-to-text');
|
||||
import { BrowserWindow, ipcMain, net } from 'electron';
|
||||
import is from 'electron-is';
|
||||
import { convert } from 'html-to-text';
|
||||
|
||||
const { cleanupName } = require('../../providers/song-info');
|
||||
const { injectCSS } = require('../utils');
|
||||
import { GetGeniusLyric } from './types';
|
||||
|
||||
import { cleanupName, SongInfo } from '../../providers/song-info';
|
||||
|
||||
import { injectCSS } from '../utils';
|
||||
import config from '../../config';
|
||||
|
||||
const eastAsianChars = /\p{Script=Katakana}|\p{Script=Hiragana}|\p{Script=Hangul}|\p{Script=Han}/u;
|
||||
let revRomanized = false;
|
||||
|
||||
module.exports = async (win, options) => {
|
||||
const LyricGeniusTypeObj = config.get('plugins.lyric-genius');
|
||||
export type LyricGeniusType = typeof LyricGeniusTypeObj;
|
||||
|
||||
export default (win: BrowserWindow, options: LyricGeniusType) => {
|
||||
if (options.romanizedLyrics) {
|
||||
revRomanized = true;
|
||||
}
|
||||
|
||||
injectCSS(win.webContents, join(__dirname, 'style.css'));
|
||||
|
||||
ipcMain.on('search-genius-lyrics', async (event, extractedSongInfo) => {
|
||||
const metadata = JSON.parse(extractedSongInfo);
|
||||
event.returnValue = await fetchFromGenius(metadata);
|
||||
ipcMain.handle('search-genius-lyrics', async (_, extractedSongInfo: string) => {
|
||||
const metadata = JSON.parse(extractedSongInfo) as SongInfo;
|
||||
return await fetchFromGenius(metadata);
|
||||
});
|
||||
};
|
||||
|
||||
const toggleRomanized = () => {
|
||||
export const toggleRomanized = () => {
|
||||
revRomanized = !revRomanized;
|
||||
};
|
||||
|
||||
const fetchFromGenius = async (metadata) => {
|
||||
export const fetchFromGenius = async (metadata: SongInfo) => {
|
||||
const songTitle = `${cleanupName(metadata.title)}`;
|
||||
const songArtist = `${cleanupName(metadata.artist)}`;
|
||||
let lyrics;
|
||||
let lyrics: string | null;
|
||||
|
||||
/* Uses Regex to test the title and artist first for said characters if romanization is enabled. Otherwise normal
|
||||
Genius Lyrics behavior is observed.
|
||||
@ -46,7 +53,7 @@ const fetchFromGenius = async (metadata) => {
|
||||
/* If the romanization toggle is on, and we did not detect any characters in the title or artist, we do a check
|
||||
for characters in the lyrics themselves. If this check proves true, we search for Romanized lyrics.
|
||||
*/
|
||||
if (revRomanized && !hasAsianChars && eastAsianChars.test(lyrics)) {
|
||||
if (revRomanized && !hasAsianChars && lyrics && eastAsianChars.test(lyrics)) {
|
||||
lyrics = await getLyricsList(`${songArtist} ${songTitle} Romanized`);
|
||||
}
|
||||
|
||||
@ -58,7 +65,7 @@ const fetchFromGenius = async (metadata) => {
|
||||
* @param {*} queryString
|
||||
* @returns The lyrics of the first song found using the Genius-Lyrics API
|
||||
*/
|
||||
const getLyricsList = async (queryString) => {
|
||||
const getLyricsList = async (queryString: string): Promise<string | null> => {
|
||||
const response = await net.fetch(
|
||||
`https://genius.com/api/search/multi?per_page=5&q=${encodeURIComponent(queryString)}`,
|
||||
);
|
||||
@ -69,17 +76,17 @@ const getLyricsList = async (queryString) => {
|
||||
/* Fetch the first URL with the api, giving a collection of song results.
|
||||
Pick the first song, parsing the json given by the API.
|
||||
*/
|
||||
const info = await response.json();
|
||||
let url = '';
|
||||
try {
|
||||
url = info.response.sections.find((section) => section.type === 'song')
|
||||
.hits[0].result.url;
|
||||
} catch {
|
||||
const info = await response.json() as GetGeniusLyric;
|
||||
const url = info
|
||||
.response
|
||||
.sections
|
||||
.find((section) => section.type === 'song')?.hits[0].result.url;
|
||||
|
||||
if (url) {
|
||||
return await getLyrics(url);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lyrics = await getLyrics(url);
|
||||
return lyrics;
|
||||
};
|
||||
|
||||
/**
|
||||
@ -87,7 +94,7 @@ const getLyricsList = async (queryString) => {
|
||||
* @param {*} url
|
||||
* @returns The lyrics of the song URL provided, null if none
|
||||
*/
|
||||
const getLyrics = async (url) => {
|
||||
const getLyrics = async (url: string): Promise<string | null> => {
|
||||
const response = await net.fetch(url);
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
@ -116,6 +123,3 @@ const getLyrics = async (url) => {
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
module.exports.toggleRomanized = toggleRomanized;
|
||||
module.exports.fetchFromGenius = fetchFromGenius;
|
||||
@ -1,8 +1,8 @@
|
||||
const { ipcRenderer } = require('electron');
|
||||
const is = require('electron-is');
|
||||
import { ipcRenderer } from 'electron';
|
||||
import is from 'electron-is';
|
||||
|
||||
module.exports = () => {
|
||||
ipcRenderer.on('update-song-info', (_, extractedSongInfo) => setTimeout(() => {
|
||||
export default () => {
|
||||
ipcRenderer.on('update-song-info', (_, extractedSongInfo: string) => setTimeout(async () => {
|
||||
const tabList = document.querySelectorAll('tp-yt-paper-tab');
|
||||
const tabs = {
|
||||
upNext: tabList[0],
|
||||
@ -17,10 +17,10 @@ module.exports = () => {
|
||||
|
||||
let hasLyrics = true;
|
||||
|
||||
const lyrics = ipcRenderer.sendSync(
|
||||
const lyrics = await ipcRenderer.invoke(
|
||||
'search-genius-lyrics',
|
||||
extractedSongInfo,
|
||||
);
|
||||
) as string;
|
||||
if (!lyrics) {
|
||||
// Delete previous lyrics if tab is open and couldn't get new lyrics
|
||||
checkLyricsContainer(() => {
|
||||
@ -40,17 +40,21 @@ module.exports = () => {
|
||||
|
||||
checkLyricsContainer();
|
||||
|
||||
tabs.lyrics.addEventListener('click', () => {
|
||||
const lyricsTabHandler = () => {
|
||||
const tabContainer = document.querySelector('ytmusic-tab-renderer');
|
||||
const observer = new MutationObserver((_, observer) => {
|
||||
checkLyricsContainer(() => observer.disconnect());
|
||||
});
|
||||
observer.observe(tabContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
});
|
||||
if (tabContainer) {
|
||||
const observer = new MutationObserver((_, observer) => {
|
||||
checkLyricsContainer(() => observer.disconnect());
|
||||
});
|
||||
observer.observe(tabContainer, {
|
||||
attributes: true,
|
||||
childList: true,
|
||||
subtree: true,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
tabs.lyrics.addEventListener('click', lyricsTabHandler);
|
||||
|
||||
function checkLyricsContainer(callback = () => {
|
||||
}) {
|
||||
@ -63,7 +67,7 @@ module.exports = () => {
|
||||
}
|
||||
}
|
||||
|
||||
function setLyrics(lyricsContainer) {
|
||||
function setLyrics(lyricsContainer: Element) {
|
||||
lyricsContainer.innerHTML = `<div id="contents" class="style-scope ytmusic-section-list-renderer description ytmusic-description-shelf-renderer genius-lyrics">
|
||||
${
|
||||
hasLyrics
|
||||
@ -74,15 +78,20 @@ module.exports = () => {
|
||||
</div>
|
||||
<yt-formatted-string class="footer style-scope ytmusic-description-shelf-renderer" style="align-self: baseline"></yt-formatted-string>`;
|
||||
if (hasLyrics) {
|
||||
lyricsContainer.querySelector('.footer').textContent = 'Source: Genius';
|
||||
enableLyricsTab();
|
||||
const footer = lyricsContainer.querySelector('.footer');
|
||||
if (footer) {
|
||||
footer.textContent = 'Source: Genius';
|
||||
enableLyricsTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setTabsOnclick(callback) {
|
||||
const defaultHandler = () => {};
|
||||
|
||||
function setTabsOnclick(callback: EventListenerOrEventListenerObject | undefined) {
|
||||
for (const tab of [tabs.upNext, tabs.discover]) {
|
||||
if (tab) {
|
||||
tab.addEventListener('click', callback);
|
||||
tab.addEventListener('click', callback ?? defaultHandler);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
},
|
||||
},
|
||||
];
|
||||
18
plugins/lyrics-genius/menu.ts
Normal file
18
plugins/lyrics-genius/menu.ts
Normal 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();
|
||||
},
|
||||
},
|
||||
];
|
||||
121
plugins/lyrics-genius/types.ts
Normal file
121
plugins/lyrics-genius/types.ts
Normal 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;
|
||||
}
|
||||
@ -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 ?? '';
|
||||
|
||||
@ -7,9 +7,10 @@ import { YoutubePlayer } from '../types/youtube-player';
|
||||
import { GetState } from '../types/datahost-get-state';
|
||||
|
||||
let songInfo: SongInfo = {} as SongInfo;
|
||||
export const getSongInfo = () => songInfo;
|
||||
|
||||
const $ = <E extends HTMLElement>(s: string): E => document.querySelector(s) as E;
|
||||
const $$ = <E extends HTMLElement>(s: string): E[] => [...document.querySelectorAll(s)!] as E[];
|
||||
const $$ = <E extends HTMLElement>(s: string): E[] => Array.from(document.querySelectorAll(s));
|
||||
|
||||
ipcRenderer.on('update-song-info', async (_, extractedSongInfo: string) => {
|
||||
songInfo = JSON.parse(extractedSongInfo) as SongInfo;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"lib": ["dom"],
|
||||
"lib": ["dom", "es2022"],
|
||||
"module": "CommonJS",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in New Issue
Block a user