interface.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385
  1. import * as UserUtils from "@sv443-network/userutils";
  2. import * as compareVersions from "compare-versions";
  3. import { createNanoEvents } from "nanoevents";
  4. import { mode, branch, host, buildNumber, compressionFormat, scriptInfo } from "./constants";
  5. import { getResourceUrl, getSessionId, getVideoTime, log, setLocale, getLocale, hasKey, hasKeyFor, NanoEmitter, t, tp, type TrLocale, info, error, onInteraction, getThumbnailUrl, getBestThumbnailUrl } from "./utils";
  6. import { addSelectorListener } from "./observers";
  7. import { getFeatures, setFeatures } from "./config";
  8. import { featInfo, fetchLyricsUrlTop, getLyricsCacheEntry, sanitizeArtists, sanitizeSong } from "./features";
  9. import { allSiteEvents, type SiteEventsMap } from "./siteEvents";
  10. import { LogLevel, type FeatureConfig, type FeatureInfo, type LyricsCacheEntry, type PluginDef, type PluginInfo, type PluginRegisterResult, type PluginDefResolvable, type PluginEventMap, type PluginItem, type BytmObject } from "./types";
  11. import { BytmDialog, createCircularBtn, createHotkeyInput, createToggleInput } from "./components";
  12. const { getUnsafeWindow, randomId } = UserUtils;
  13. //#region interface globals
  14. /** All events that can be emitted on the BYTM interface and the data they provide */
  15. export type InterfaceEventsMap = {
  16. [K in keyof InterfaceEvents]: (data: InterfaceEvents[K]) => void;
  17. };
  18. /** All events that can be emitted on the BYTM interface and the data they provide */
  19. export type InterfaceEvents = {
  20. /** Emitted whenever the plugins should be registered using `unsafeWindow.BYTM.registerPlugin()` */
  21. "bytm:initPlugins": undefined;
  22. /** Emitted whenever all plugins have been loaded */
  23. "bytm:pluginsRegistered": undefined;
  24. /** Emitted when BYTM has finished initializing all features */
  25. "bytm:ready": undefined;
  26. /** Emitted when a fatal error occurs and the script can't continue to run. Returns a short error description (not really meant to be displayed to the user). */
  27. "bytm:fatalError": string;
  28. /**
  29. * Emitted whenever the SelectorObserver instances have been initialized
  30. * Use `unsafeWindow.BYTM.addObserverListener()` to add custom listener functions to the observers
  31. */
  32. "bytm:observersReady": undefined;
  33. /** Emitted as soon as the feature config has finished loading and can be accessed via `unsafeWindow.BYTM.getFeatures(token)` */
  34. "bytm:configReady": undefined;
  35. /** Emitted whenever the locale is changed */
  36. "bytm:setLocale": { locale: TrLocale, pluginId?: string };
  37. /** Emitted when a dialog was opened - returns the dialog's instance */
  38. "bytm:dialogOpened": BytmDialog;
  39. /** 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 */
  40. "bytm:dialogOpened:id": BytmDialog;
  41. /** Emitted whenever the lyrics URL for a song is loaded */
  42. "bytm:lyricsLoaded": { type: "current" | "queue", artists: string, title: string, url: string };
  43. /** Emitted when the lyrics cache has been loaded */
  44. "bytm:lyricsCacheReady": undefined;
  45. /** Emitted when the lyrics cache has been cleared */
  46. "bytm:lyricsCacheCleared": undefined;
  47. /** 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 */
  48. "bytm:lyricsCacheEntryAdded": { type: "best" | "penalized", entry: LyricsCacheEntry };
  49. // additionally all events from SiteEventsMap in `src/siteEvents.ts`
  50. // are emitted in this format: "bytm:siteEvent:nameOfSiteEvent"
  51. };
  52. export const allInterfaceEvents = [
  53. "bytm:initPlugins",
  54. "bytm:pluginsRegistered",
  55. "bytm:ready",
  56. "bytm:fatalError",
  57. "bytm:observersReady",
  58. "bytm:configReady",
  59. "bytm:setLocale",
  60. "bytm:dialogOpened",
  61. "bytm:dialogOpened:id",
  62. "bytm:lyricsLoaded",
  63. "bytm:lyricsCacheReady",
  64. "bytm:lyricsCacheCleared",
  65. "bytm:lyricsCacheEntryAdded",
  66. ...allSiteEvents.map(e => `bytm:siteEvent:${e}`),
  67. ] as const;
  68. /** All functions that can be called on the BYTM interface using `unsafeWindow.BYTM.functionName();` (or `const { functionName } = unsafeWindow.BYTM;`) */
  69. const globalFuncs = {
  70. // meta
  71. registerPlugin,
  72. getPluginInfo,
  73. // utils
  74. addSelectorListener,
  75. getResourceUrl,
  76. getSessionId,
  77. getVideoTime,
  78. setLocale: setLocaleInterface,
  79. getLocale,
  80. hasKey,
  81. hasKeyFor,
  82. t,
  83. tp,
  84. getFeatures: getFeaturesInterface,
  85. saveFeatures: saveFeaturesInterface,
  86. fetchLyricsUrlTop,
  87. getLyricsCacheEntry,
  88. sanitizeArtists,
  89. sanitizeSong,
  90. onInteraction,
  91. getThumbnailUrl,
  92. getBestThumbnailUrl,
  93. createHotkeyInput,
  94. createToggleInput,
  95. createCircularBtn,
  96. };
  97. /** Initializes the BYTM interface */
  98. export function initInterface() {
  99. const props = {
  100. // meta / constants
  101. mode,
  102. branch,
  103. host,
  104. buildNumber,
  105. compressionFormat,
  106. ...scriptInfo,
  107. // functions
  108. ...globalFuncs,
  109. // classes
  110. NanoEmitter,
  111. BytmDialog,
  112. // libraries
  113. UserUtils,
  114. compareVersions,
  115. };
  116. for(const [key, value] of Object.entries(props))
  117. setGlobalProp(key, value);
  118. log("Initialized BYTM interface");
  119. }
  120. /** Sets a global property on the unsafeWindow.BYTM object */
  121. export function setGlobalProp<
  122. TKey extends keyof Window["BYTM"],
  123. TValue = Window["BYTM"][TKey],
  124. >(
  125. key: TKey | (string & {}),
  126. value: TValue,
  127. ) {
  128. // use unsafeWindow so the properties are available to plugins outside of the userscript's scope
  129. const win = getUnsafeWindow();
  130. if(typeof win.BYTM !== "object")
  131. win.BYTM = {} as BytmObject;
  132. win.BYTM[key] = value;
  133. }
  134. /** Emits an event on the BYTM interface */
  135. export function emitInterface<
  136. TEvt extends keyof InterfaceEvents,
  137. TDetail extends InterfaceEvents[TEvt],
  138. >(
  139. type: TEvt | `bytm:siteEvent:${keyof SiteEventsMap}`,
  140. ...detail: (TDetail extends undefined ? [undefined?] : [TDetail])
  141. ) {
  142. getUnsafeWindow().dispatchEvent(new CustomEvent(type, { detail: detail?.[0] ?? undefined }));
  143. //@ts-ignore
  144. emitOnPlugins(type, undefined, ...detail);
  145. log(`Emitted interface event '${type}'${detail && detail.length > 0 ? " with data:" : ""}`, ...detail);
  146. }
  147. //#region register plugins
  148. /** Map of plugin ID and plugins that are queued up for registration */
  149. const pluginsQueued = new Map<string, PluginItem>();
  150. /** Map of plugin ID and all registered plugins */
  151. const pluginsRegistered = new Map<string, PluginItem>();
  152. /** Map of plugin ID to auth token for plugins that have been registered */
  153. const pluginTokens = new Map<string, string>();
  154. /** Initializes plugins that have been registered already. Needs to be run after `bytm:ready`! */
  155. export function initPlugins() {
  156. // TODO(v1.3): check perms and ask user for initial activation
  157. for(const [key, { def, events }] of pluginsQueued) {
  158. try {
  159. pluginsRegistered.set(key, { def, events });
  160. pluginsQueued.delete(key);
  161. emitOnPlugins("pluginRegistered", (d) => sameDef(d, def), pluginDefToInfo(def)!);
  162. }
  163. catch(err) {
  164. error(`Failed to initialize plugin '${getPluginKey(def)}':`, err);
  165. }
  166. }
  167. emitInterface("bytm:pluginsRegistered");
  168. }
  169. /** Returns the key for a given plugin definition */
  170. function getPluginKey(plugin: PluginDefResolvable) {
  171. return `${plugin.plugin.namespace}/${plugin.plugin.name}`;
  172. }
  173. /** Converts a PluginDef object (full definition) into a PluginInfo object (restricted definition) or undefined, if undefined is passed */
  174. function pluginDefToInfo(plugin?: PluginDef): PluginInfo | undefined {
  175. return plugin
  176. ? {
  177. name: plugin.plugin.name,
  178. namespace: plugin.plugin.namespace,
  179. version: plugin.plugin.version,
  180. }
  181. : undefined;
  182. }
  183. /** Checks whether two plugins are the same, given their resolvable definition objects */
  184. function sameDef(def1: PluginDefResolvable, def2: PluginDefResolvable) {
  185. return getPluginKey(def1) === getPluginKey(def2);
  186. }
  187. /** Emits an event on all plugins that match the predicate (all plugins by default) */
  188. export function emitOnPlugins<TEvtKey extends keyof PluginEventMap>(
  189. event: TEvtKey,
  190. predicate: ((def: PluginDef) => boolean) | boolean = true,
  191. ...data: Parameters<PluginEventMap[TEvtKey]>
  192. ) {
  193. for(const { def, events } of pluginsRegistered.values())
  194. if(typeof predicate === "boolean" ? predicate : predicate(def))
  195. events.emit(event, ...data);
  196. }
  197. /**
  198. * @private FOR INTERNAL USE ONLY!
  199. * Returns the internal plugin def and events objects via its name and namespace, or undefined if it doesn't exist.
  200. */
  201. export function getPlugin(pluginName: string, namespace: string): PluginItem | undefined
  202. /**
  203. * @private FOR INTERNAL USE ONLY!
  204. * Returns the internal plugin def and events objects via resolvable definition, or undefined if it doesn't exist.
  205. */
  206. export function getPlugin(pluginDef: PluginDefResolvable): PluginItem | undefined
  207. /**
  208. * @private FOR INTERNAL USE ONLY!
  209. * Returns the internal plugin def and events objects via plugin ID (consisting of namespace and name), or undefined if it doesn't exist.
  210. */
  211. export function getPlugin(pluginId: string): PluginItem | undefined
  212. /**
  213. * @private FOR INTERNAL USE ONLY!
  214. * Returns the internal plugin def and events objects, or undefined if it doesn't exist.
  215. */
  216. export function getPlugin(...args: [pluginDefOrNameOrId: PluginDefResolvable | string, namespace?: string]): PluginItem | undefined {
  217. return typeof args[0] === "string" && typeof args[1] === "undefined"
  218. ? pluginsRegistered.get(args[0])
  219. : args.length === 2
  220. ? pluginsRegistered.get(`${args[1]}/${args[0]}`)
  221. : pluginsRegistered.get(getPluginKey(args[0] as PluginDefResolvable));
  222. }
  223. /**
  224. * Returns info about a registered plugin on the BYTM interface by its name and namespace properties, or undefined if the plugin isn't registered.
  225. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  226. * @public Intended for general use in plugins.
  227. */
  228. export function getPluginInfo(token: string | undefined, name: string, namespace: string): PluginInfo | undefined
  229. /**
  230. * Returns info about a registered plugin on the BYTM interface by a resolvable definition object, or undefined if the plugin isn't registered.
  231. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  232. * @public Intended for general use in plugins.
  233. */
  234. export function getPluginInfo(token: string | undefined, plugin: PluginDefResolvable): PluginInfo | undefined
  235. /**
  236. * 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.
  237. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  238. * @public Intended for general use in plugins.
  239. */
  240. export function getPluginInfo(token: string | undefined, pluginId: string): PluginInfo | undefined
  241. /**
  242. * Returns info about a registered plugin on the BYTM interface, or undefined if the plugin isn't registered.
  243. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  244. * @public Intended for general use in plugins.
  245. */
  246. export function getPluginInfo(...args: [token: string | undefined, pluginDefOrNameOrId: PluginDefResolvable | string, namespace?: string]): PluginInfo | undefined {
  247. if(resolveToken(args[0]) === undefined)
  248. return undefined;
  249. return pluginDefToInfo(
  250. pluginsRegistered.get(
  251. typeof args[1] === "string" && typeof args[2] === "undefined"
  252. ? args[1]
  253. : args.length === 2
  254. ? `${args[2]}/${args[1]}`
  255. : getPluginKey(args[1] as PluginDefResolvable)
  256. )?.def
  257. );
  258. }
  259. /** Validates the passed PluginDef object and returns an array of errors - returns undefined if there were no errors - never returns an empty array */
  260. function validatePluginDef(pluginDef: Partial<PluginDef>) {
  261. const errors = [] as string[];
  262. const addNoPropErr = (jsonPath: string, type: string) =>
  263. errors.push(t("plugin_validation_error_no_property", jsonPath, type));
  264. const addInvalidPropErr = (jsonPath: string, value: string, examples: string[]) =>
  265. errors.push(tp("plugin_validation_error_invalid_property", examples, jsonPath, value, `'${examples.join("', '")}'`));
  266. // def.plugin and its properties:
  267. typeof pluginDef.plugin !== "object" && addNoPropErr("plugin", "object");
  268. const { plugin } = pluginDef;
  269. !plugin?.name && addNoPropErr("plugin.name", "string");
  270. !plugin?.namespace && addNoPropErr("plugin.namespace", "string");
  271. if(typeof plugin?.version !== "string")
  272. addNoPropErr("plugin.version", "MAJOR.MINOR.PATCH");
  273. else if(!compareVersions.validateStrict(plugin.version))
  274. addInvalidPropErr("plugin.version", plugin.version, ["0.0.1", "2.5.21-rc.1"]);
  275. return errors.length > 0 ? errors : undefined;
  276. }
  277. /** Registers a plugin on the BYTM interface */
  278. export function registerPlugin(def: PluginDef): PluginRegisterResult {
  279. const validationErrors = validatePluginDef(def);
  280. if(validationErrors) {
  281. error(`Failed to register plugin${def?.plugin?.name ? ` '${def?.plugin?.name}'` : ""} with invalid definition:\n- ${validationErrors.join("\n- ")}`, LogLevel.Info);
  282. throw new Error(`Invalid plugin definition:\n- ${validationErrors.join("\n- ")}`);
  283. }
  284. const events = createNanoEvents<PluginEventMap>();
  285. const token = randomId(32, 36);
  286. const { plugin: { name } } = def;
  287. pluginsQueued.set(getPluginKey(def), {
  288. def: def,
  289. events,
  290. });
  291. pluginTokens.set(getPluginKey(def), token);
  292. info(`Registered plugin: ${name}`, LogLevel.Info);
  293. return {
  294. info: getPluginInfo(token, def)!,
  295. events,
  296. token,
  297. };
  298. }
  299. /** Checks whether the passed token is a valid auth token for any registered plugin and returns the plugin ID, else returns undefined */
  300. export function resolveToken(token: string | undefined): string | undefined {
  301. return token ? [...pluginTokens.entries()].find(([, v]) => v === token)?.[0] ?? undefined : undefined;
  302. }
  303. //#region proxy funcs
  304. /**
  305. * Sets the new locale on the BYTM interface
  306. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  307. */
  308. function setLocaleInterface(token: string | undefined, locale: TrLocale) {
  309. const pluginId = resolveToken(token);
  310. if(pluginId === undefined)
  311. return;
  312. setLocale(locale);
  313. emitInterface("bytm:setLocale", { pluginId, locale });
  314. }
  315. /**
  316. * Returns the current feature config, with sensitive values replaced by `undefined`
  317. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  318. */
  319. function getFeaturesInterface(token: string | undefined) {
  320. if(resolveToken(token) === undefined)
  321. return undefined;
  322. const features = getFeatures();
  323. for(const ftKey of Object.keys(features)) {
  324. const info = featInfo[ftKey as keyof typeof featInfo] as FeatureInfo[keyof FeatureInfo];
  325. if(info && info.valueHidden) // @ts-ignore
  326. features[ftKey as keyof typeof features] = undefined;
  327. }
  328. return features as FeatureConfig;
  329. }
  330. /**
  331. * Saves the passed feature config synchronously to the in-memory cache and asynchronously to the persistent storage.
  332. * This is an authenticated function so you must pass the session- and plugin-unique token, retreived at registration.
  333. */
  334. function saveFeaturesInterface(token: string | undefined, features: FeatureConfig) {
  335. if(resolveToken(token) === undefined)
  336. return;
  337. setFeatures(features);
  338. }