1
0

interface.ts 19 KB

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