123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468 |
- import * as UserUtils from "@sv443-network/userutils";
- import * as compareVersions from "compare-versions";
- import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants.js";
- import { getDomain, waitVideoElementReady, getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, t, tp, type TrLocale, info, error, onInteraction, getThumbnailUrl, getBestThumbnailUrl, fetchVideoVotes, setInnerHtmlTrusted } from "./utils/index.js";
- import { addSelectorListener } from "./observers.js";
- import { getFeatures, setFeatures } from "./config.js";
- import { autoLikeStore, featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong } from "./features/index.js";
- import { allSiteEvents, type SiteEventsMap } from "./siteEvents.js";
- import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable, type PluginEventMap, type PluginItem, type BytmObject, type AutoLikeData, type InterfaceFunctions } from "./types.js";
- import { BytmDialog, ExImDialog, MarkdownDialog, createCircularBtn, createHotkeyInput, createRipple, createToggleInput, showIconToast, showToast } from "./components/index.js";
- const { getUnsafeWindow, randomId, NanoEmitter } = UserUtils;
- //#region interface globals
- /** All events that can be emitted on the BYTM interface and the data they provide */
- export type InterfaceEventsMap = {
- [K in keyof InterfaceEvents]: (data: InterfaceEvents[K]) => void;
- };
- /** All events that can be emitted on the BYTM interface and the data they provide */
- export type InterfaceEvents = {
- //#region startup events
- // (sorted in order of execution)
- /** Emitted as soon as the feature config has finished loading and can be accessed via `unsafeWindow.BYTM.getFeatures(token)` */
- "bytm:configReady": undefined;
- /** Emitted when the lyrics cache has been loaded */
- "bytm:lyricsCacheReady": undefined;
- /** Emitted whenever the locale is changed - if a plugin changed the locale, the plugin ID is provided as well */
- "bytm:setLocale": { locale: TrLocale, pluginId?: string };
- /**
- * When this is emitted, this is your call to register your plugin using `unsafeWindow.BYTM.registerPlugin()`
- * To be safe, you should wait for this event before doing anything else in your plugin script.
- */
- "bytm:registerPlugins": undefined;
- /**
- * Emitted whenever the SelectorObserver instances have been initialized and can be used to listen for DOM changes and wait for elements to be available.
- * Use `unsafeWindow.BYTM.addObserverListener(name, selector, opts)` to add custom listener functions to the observers (see contributing guide).
- */
- "bytm:observersReady": undefined;
- /**
- * Emitted when the feature initialization has started.
- * This is the last event that is emitted before the `bytm:ready` event.
- * As soon as this is emitted, you cannot register any more plugins.
- */
- "bytm:featureInitStarted": undefined;
- /**
- * Emitted whenever all plugins have been registered and are allowed to call token-authenticated functions.
- * All parts of your plugin that require those functions should wait for this event to be emitted.
- */
- "bytm:pluginsRegistered": undefined;
- /** Emitted when a feature has been initialized. The data is the feature's key as seen in `onDomLoad()` of `src/index.ts` */
- "bytm:featureInitialized": string;
- /** Emitted when BYTM has finished initializing all features or has reached the init timeout and has entered an idle state. */
- "bytm:ready": undefined;
- //#region additional events
- // (not sorted)
- /**
- * Emitted when a fatal error occurs and the script can't continue to run.
- * Returns a short error description that's not really meant to be displayed to the user (console is fine).
- * But may be helpful in plugin development if the plugin causes an internal error.
- */
- "bytm:fatalError": string;
- /** Emitted when a dialog was opened - returns the dialog's instance */
- "bytm:dialogOpened": BytmDialog;
- /** Emitted when the dialog with the specified ID was opened - returns the dialog's instance - in TS, use `"bytm:dialogOpened:myIdWhatever" as "bytm:dialogOpened:id"` to make the error go away */
- "bytm:dialogOpened:id": BytmDialog;
- /** Emitted when a dialog was closed - returns the dialog's instance */
- "bytm:dialogClosed": BytmDialog;
- /** Emitted when the dialog with the specified ID was closed - returns the dialog's instance - in TS, use `"bytm:dialogClosed:myIdWhatever" as "bytm:dialogClosed:id"` to make the error go away */
- "bytm:dialogClosed:id": BytmDialog;
- /** Emitted whenever the lyrics URL for a song is loaded */
- "bytm:lyricsLoaded": { type: "current" | "queue", artists: string, title: string, url: string };
- /** Emitted when the lyrics cache has been cleared */
- "bytm:lyricsCacheCleared": undefined;
- /** Emitted when an entry is added to the lyrics cache - "penalized" entries get removed from cache faster because they were less related in lyrics lookups, opposite to the "best" entries */
- "bytm:lyricsCacheEntryAdded": { type: "best" | "penalized", entry: LyricsCacheEntry };
- // NOTE:
- // Additionally, all events from `SiteEventsMap` in `src/siteEvents.ts`
- // are emitted in this format: "bytm:siteEvent:nameOfSiteEvent"
- };
- /** Array of all events emittable on the interface (excluding plugin-specific, private events) */
- export const allInterfaceEvents = [
- "bytm:registerPlugins",
- "bytm:pluginsRegistered",
- "bytm:ready",
- "bytm:featureInitfeatureInitStarted",
- "bytm:fatalError",
- "bytm:observersReady",
- "bytm:configReady",
- "bytm:setLocale",
- "bytm:dialogOpened",
- "bytm:dialogOpened:id",
- "bytm:lyricsLoaded",
- "bytm:lyricsCacheReady",
- "bytm:lyricsCacheCleared",
- "bytm:lyricsCacheEntryAdded",
- ...allSiteEvents.map(e => `bytm:siteEvent:${e}`),
- ] as const;
- /**
- * All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`)
- * If prefixed with /**\/, the function is authenticated and requires a token to be passed as the first argument.
- */
- const globalFuncs: InterfaceFunctions = {
- // meta:
- registerPlugin,
- /**/ getPluginInfo,
- // bytm-specific:
- getDomain,
- getResourceUrl,
- getSessionId,
- // dom:
- setInnerHtmlTrusted,
- addSelectorListener,
- onInteraction,
- getVideoTime,
- getThumbnailUrl,
- getBestThumbnailUrl,
- waitVideoElementReady,
- // translations:
- /**/ setLocale: setLocaleInterface,
- getLocale,
- hasKey,
- hasKeyFor,
- t,
- tp,
- // feature config:
- /**/ getFeatures: getFeaturesInterface,
- /**/ saveFeatures: saveFeaturesInterface,
- // lyrics:
- fetchLyricsUrlTop,
- getLyricsCacheEntry,
- sanitizeArtists,
- sanitizeSong,
- // auto-like:
- /**/ getAutoLikeData: getAutoLikeDataInterface,
- /**/ saveAutoLikeData: saveAutoLikeDataInterface,
- fetchVideoVotes,
- // components:
- createHotkeyInput,
- createToggleInput,
- createCircularBtn,
- createRipple,
- showToast,
- showIconToast,
- };
- /** Initializes the BYTM interface */
- export function initInterface() {
- const props = {
- // meta / constants
- mode,
- branch,
- host,
- buildNumber,
- compressionFormat,
- ...scriptInfo,
- // functions
- ...globalFuncs,
- // classes
- NanoEmitter,
- BytmDialog,
- ExImDialog,
- MarkdownDialog,
- // libraries
- UserUtils,
- compareVersions,
- };
- for(const [key, value] of Object.entries(props))
- setGlobalProp(key, value);
- log("Initialized BYTM interface");
- }
- /** Sets a global property on the unsafeWindow.BYTM object */
- export function setGlobalProp<
- TKey extends keyof Window["BYTM"],
- TValue = Window["BYTM"][TKey],
- >(
- key: TKey | (string & {}),
- value: TValue,
- ) {
- // use unsafeWindow so the properties are available to plugins outside of the userscript's scope
- const win = getUnsafeWindow();
- if(typeof win.BYTM !== "object")
- win.BYTM = {} as BytmObject;
- win.BYTM[key] = value;
- }
- /** Emits an event on the BYTM interface */
- export function emitInterface<
- TEvt extends keyof InterfaceEvents,
- TDetail extends InterfaceEvents[TEvt],
- >(
- type: TEvt | `bytm:siteEvent:${keyof SiteEventsMap}`,
- ...detail: (TDetail extends undefined ? [undefined?] : [TDetail])
- ) {
- try {
- getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: detail?.[0] ?? undefined }));
- //@ts-ignore
- emitOnPlugins(type, undefined, ...detail);
- log(`Emitted interface event '${type}'${detail.length > 0 && detail?.[0] ? " with data:" : ""}`, ...detail);
- }
- catch(err) {
- error(`Couldn't emit interface event '${type}' due to an error:\n`, err);
- }
- }
- //#region register plugins
- /** Map of plugin ID and plugins that are queued up for registration */
- const queuedPlugins = new Map<string, PluginItem>();
- /** Map of plugin ID and all registered plugins */
- const registeredPlugins = new Map<string, PluginItem>();
- /** Map of plugin ID to auth token for plugins that have been registered */
- const registeredPluginTokens = new Map<string, string>();
- /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
- export function initPlugins() {
- // TODO(v1.3): check perms and ask user for initial activation
- for(const [key, { def, events }] of queuedPlugins) {
- try {
- registeredPlugins.set(key, { def, events });
- queuedPlugins.delete(key);
- emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)!);
- info(`Initialized plugin '${getPluginKey(def)}'`, LogLevel.Info);
- }
- catch(err) {
- error(`Failed to initialize plugin '${getPluginKey(def)}':`, err);
- }
- }
- emitInterface("bytm:pluginsRegistered");
- }
- /** Returns the key for a given plugin definition */
- function getPluginKey(plugin: PluginDefResolvable) {
- return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
- }
- /** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
- function pluginDefToInfo(plugin?: PluginDef): PluginInfo | undefined {
- return plugin
- ? {
- name: plugin.plugin.name,
- namespace: plugin.plugin.namespace,
- version: plugin.plugin.version,
- }
- : undefined;
- }
- /** Checks whether two plugins are the same, given their resolvable definition objects */
- function sameDef(def1: PluginDefResolvable, def2: PluginDefResolvable) {
- return getPluginKey(def1) === getPluginKey(def2);
- }
- /** Emits an event on all plugins that match the predicate (all plugins by default) */
- export function emitOnPlugins<TEvtKey extends keyof PluginEventMap>(
- event: TEvtKey,
- predicate: ((def: PluginDef) => boolean) | boolean = true,
- ...data: Parameters<PluginEventMap[TEvtKey]>
- ) {
- for(const { def, events } of registeredPlugins.values())
- if(typeof predicate === "boolean" ? predicate : predicate(def))
- events.emit(event, ...data);
- }
- /**
- * @private FOR INTERNAL USE ONLY!
- * Returns the internal plugin def and events objects via its name and namespace, or undefined if it doesn't exist.
- */
- export function getPlugin(pluginName: string, namespace: string): PluginItem | undefined
- /**
- * @private FOR INTERNAL USE ONLY!
- * Returns the internal plugin def and events objects via resolvable definition, or undefined if it doesn't exist.
- */
- export function getPlugin(pluginDef: PluginDefResolvable): PluginItem | undefined
- /**
- * @private FOR INTERNAL USE ONLY!
- * Returns the internal plugin def and events objects via plugin ID (consisting of namespace and name), or undefined if it doesn't exist.
- */
- export function getPlugin(pluginId: string): PluginItem | undefined
- /**
- * @private FOR INTERNAL USE ONLY!
- * Returns the internal plugin def and events objects, or undefined if it doesn't exist.
- */
- export function getPlugin(...args: [pluginDefOrNameOrId: PluginDefResolvable | string, namespace?: string]): PluginItem | undefined {
- return typeof args[0] === "string" && typeof args[1] === "undefined"
- ? registeredPlugins.get(args[0])
- : args.length === 2
- ? registeredPlugins.get(`${args[1]}/${args[0]}`)
- : registeredPlugins.get(getPluginKey(args[0] as PluginDefResolvable));
- }
- /**
- * Returns info about a registered plugin on the BYTM interface by its name and namespace properties, or undefined if the plugin isn't registered.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- * @public Intended for general use in plugins.
- */
- export function getPluginInfo(token: string | undefined, name: string, namespace: string): PluginInfo | undefined
- /**
- * Returns info about a registered plugin on the BYTM interface by a resolvable definition object, or undefined if the plugin isn't registered.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- * @public Intended for general use in plugins.
- */
- export function getPluginInfo(token: string | undefined, plugin: PluginDefResolvable): PluginInfo | undefined
- /**
- * Returns info about a registered plugin on the BYTM interface by its ID (consisting of namespace and name), or undefined if the plugin isn't registered.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- * @public Intended for general use in plugins.
- */
- export function getPluginInfo(token: string | undefined, pluginId: string): PluginInfo | undefined
- /**
- * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- * @public Intended for general use in plugins.
- */
- export function getPluginInfo(...args: [token: string | undefined, pluginDefOrNameOrId: PluginDefResolvable | string, namespace?: string]): PluginInfo | undefined {
- if(resolveToken(args[0]) === undefined)
- return undefined;
- return pluginDefToInfo(
- registeredPlugins.get(
- typeof args[1] === "string" && typeof args[2] === "undefined"
- ? args[1]
- : args.length === 2
- ? `${args[2]}/${args[1]}`
- : getPluginKey(args[1] as PluginDefResolvable)
- )?.def
- );
- }
- /** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */
- function validatePluginDef(pluginDef: Partial<PluginDef>) {
- const errors = [] as string[];
- const addNoPropErr = (jsonPath: string, type: string) =>
- errors.push(t("plugin_validation_error_no_property", jsonPath, type));
- const addInvalidPropErr = (jsonPath: string, value: string, examples: string[]) =>
- errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`));
- // def.plugin and its properties:
- typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
- const { plugin } = pluginDef;
- !plugin?.name && addNoPropErr("plugin.name", "string");
- !plugin?.namespace && addNoPropErr("plugin.namespace", "string");
- if(typeof plugin?.version !== "string")
- addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH");
- else if(!compareVersions.validateStrict(plugin.version))
- addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]);
- return errors.length > 0 ? errors : undefined;
- }
- /** Registers a plugin on the BYTM interface */
- export function registerPlugin(def: PluginDef): PluginRegisterResult {
- const validationErrors = validatePluginDef(def);
- if(validationErrors)
- throw new Error(`Failed to register plugin${def?.plugin?.name ? ` '${def?.plugin?.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`);
- const events = new NanoEmitter<PluginEventMap>({ publicEmit: true });
- const token = randomId(32, 36);
- const { plugin: { name } } = def;
- queuedPlugins.set(getPluginKey(def), {
- def: def,
- events,
- });
- registeredPluginTokens.set(getPluginKey(def), token);
- info(`Registered plugin: ${name}`, LogLevel.Info);
- return {
- info: getPluginInfo(token, def)!,
- events,
- token,
- };
- }
- /** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */
- export function resolveToken(token: string | undefined): string | undefined {
- return typeof token === "string" && token.length > 0
- ? [...registeredPluginTokens.entries()]
- .find(([k, t]) => registeredPlugins.has(k) && token === t)?.[0] ?? undefined
- : undefined;
- }
- //#region proxy funcs
- /**
- * Sets the new locale on the BYTM interface
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- export function setLocaleInterface(token: string | undefined, locale: TrLocale) {
- const pluginId = resolveToken(token);
- if(pluginId === undefined)
- return;
- setLocale(locale);
- emitInterface("bytm:setLocale", { pluginId, locale });
- }
- /**
- * Returns the current feature config, with sensitive values replaced by `undefined`
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- export function getFeaturesInterface(token: string | undefined) {
- if(resolveToken(token) === undefined)
- return undefined;
- const features = getFeatures();
- for(const ftKey of Object.keys(features)) {
- const info = featInfo[ftKey as keyof typeof featInfo] as FeatureInfo[keyof FeatureInfo];
- if(info && info.valueHidden) // @ts-ignore
- features[ftKey as keyof typeof features] = undefined;
- }
- return features as FeatureConfig;
- }
- /**
- * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- export function saveFeaturesInterface(token: string | undefined, features: FeatureConfig) {
- if(resolveToken(token) === undefined)
- return;
- setFeatures(features);
- }
- /**
- * Returns the auto-like data.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- export function getAutoLikeDataInterface(token: string | undefined) {
- if(resolveToken(token) === undefined)
- return;
- return autoLikeStore.getData();
- }
- /**
- * Saves new auto-like data, synchronously to the in-memory cache and asynchronously to the persistent storage.
- * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
- */
- export function saveAutoLikeDataInterface(token: string | undefined, data: AutoLikeData) {
- if(resolveToken(token) === undefined)
- return;
- return autoLikeStore.setData(data);
- }
|