interface.ts 18 KB

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