misc.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { compress, consumeStringGen, decompress, fetchAdvanced, getUnsafeWindow, openInNewTab, pauseFor, randomId, randRange, type Prettify, type StringGen } from "@sv443-network/userutils";
  2. import { marked } from "marked";
  3. import { assetSource, buildNumber, changelogUrl, compressionFormat, devServerPort, repo, sessionStorageAvailable } from "../constants.js";
  4. import { type Domain, type NumberLengthFormat, type ResourceKey } from "../types.js";
  5. import { error, type TrLocale, warn, sendRequest, getLocale, log, getVideoElement, getVideoTime } from "./index.js";
  6. import { enableDiscardBeforeUnload } from "../features/behavior.js";
  7. import { getFeature } from "../config.js";
  8. import langMapping from "../../assets/locales.json" with { type: "json" };
  9. import resourcesJson from "../../assets/resources.json" with { type: "json" };
  10. //#region misc
  11. let domain: Domain;
  12. /**
  13. * Returns the current domain as a constant string representation
  14. * @throws Throws if script runs on an unexpected website
  15. */
  16. export function getDomain(): Domain {
  17. if(domain)
  18. return domain;
  19. if(location.hostname.match(/^music\.youtube/))
  20. return domain = "ytm";
  21. else if(location.hostname.match(/youtube\./))
  22. return domain = "yt";
  23. else
  24. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  25. }
  26. /** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */
  27. export function getSessionId(): string | null {
  28. try {
  29. if(!sessionStorageAvailable)
  30. throw new Error("Session storage unavailable");
  31. let sesId = window.sessionStorage.getItem("_bytm-session-id");
  32. if(!sesId)
  33. window.sessionStorage.setItem("_bytm-session-id", sesId = randomId(10, 36));
  34. return sesId;
  35. }
  36. catch(err) {
  37. warn("Couldn't get session ID, sessionStorage / cookies might be disabled:", err);
  38. return null;
  39. }
  40. }
  41. let isCompressionSupported: boolean | undefined;
  42. /** Tests whether compression via the predefined {@linkcode compressionFormat} is supported (only on the first call, then returns the cached result) */
  43. export async function compressionSupported() {
  44. if(typeof isCompressionSupported === "boolean")
  45. return isCompressionSupported;
  46. try {
  47. await compress(".", compressionFormat, "string");
  48. return isCompressionSupported = true;
  49. }
  50. catch {
  51. return isCompressionSupported = false;
  52. }
  53. }
  54. /** Returns a string with the given array's items separated by a default separator (`", "` by default), with an optional different separator for the last item */
  55. export function arrayWithSeparators<TArray>(array: TArray[], separator = ", ", lastSeparator?: string) {
  56. const arr = [...array];
  57. if(!lastSeparator)
  58. lastSeparator = separator;
  59. if(arr.length === 0)
  60. return "";
  61. else if(arr.length <= 2)
  62. return arr.join(lastSeparator);
  63. else
  64. return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)!}`;
  65. }
  66. /** Returns the watch ID of the current video or null if not on a video page */
  67. export function getWatchId() {
  68. const { searchParams, pathname } = new URL(location.href);
  69. return pathname.includes("/watch") ? searchParams.get("v") : null;
  70. }
  71. /**
  72. * Returns the ID of the current channel in the format `@User` or `UC...` from URLs with the path `/@User`, `/@User/videos`, `/channel/UC...` or `/channel/UC.../videos`
  73. * Returns null if the current page is not a channel page or there was an error parsing the URL
  74. */
  75. export function getCurrentChannelId() {
  76. return parseChannelIdFromUrl(location.href);
  77. }
  78. /** Returns the channel ID from a URL or null if the URL is invalid */
  79. export function parseChannelIdFromUrl(url: string | URL) {
  80. try {
  81. const { pathname } = url instanceof URL ? url : new URL(url);
  82. if(pathname.includes("/channel/"))
  83. return sanitizeChannelId(pathname.split("/channel/")[1].split("/")[0]);
  84. else if(pathname.includes("/@"))
  85. return sanitizeChannelId(pathname.split("/@")[1].split("/")[0]);
  86. else
  87. return null;
  88. }
  89. catch {
  90. return null;
  91. }
  92. }
  93. /** Sanitizes a channel ID by adding a leading `@` if the ID doesn't start with `UC...` */
  94. export function sanitizeChannelId(channelId: string) {
  95. channelId = String(channelId).trim();
  96. return isValidChannelId(channelId) || channelId.startsWith("@")
  97. ? channelId
  98. : `@${channelId}`;
  99. }
  100. /** Tests whether a string is a valid channel ID in the format `@User` or `UC...` */
  101. export function isValidChannelId(channelId: string) {
  102. return channelId.match(/^(UC|@)[a-zA-Z0-9_-]+$/) !== null;
  103. }
  104. /** Quality identifier for a thumbnail - from highest to lowest res: `maxresdefault` > `sddefault` > `hqdefault` > `mqdefault` > `default` */
  105. type ThumbQuality = `${"maxres" | "sd" | "hq" | "mq"}default` | "default";
  106. /** Returns the thumbnail URL for a video with the given watch ID and quality (defaults to "hqdefault") */
  107. export function getThumbnailUrl(watchId: string, quality?: ThumbQuality): string
  108. /** Returns the thumbnail URL for a video with the given watch ID and index (0 is low quality thumbnail, 1-3 are low quality frames from the video) */
  109. export function getThumbnailUrl(watchId: string, index?: 0 | 1 | 2 | 3): string
  110. /** Returns the thumbnail URL for a video with either a given quality identifier or index */
  111. export function getThumbnailUrl(watchId: string, qualityOrIndex: Prettify<ThumbQuality | 0 | 1 | 2 | 3> = "maxresdefault") {
  112. return `https://img.youtube.com/vi/${watchId}/${qualityOrIndex}.jpg`;
  113. }
  114. /** Returns the best available thumbnail URL for a video with the given watch ID */
  115. export async function getBestThumbnailUrl(watchId: string) {
  116. try {
  117. const priorityList = ["maxresdefault", "sddefault", "hqdefault", 0];
  118. for(const quality of priorityList) {
  119. let response: GM.Response<unknown> | undefined;
  120. const url = getThumbnailUrl(watchId, quality as ThumbQuality);
  121. try {
  122. response = await sendRequest({ url, method: "HEAD", timeout: 6_000 });
  123. }
  124. catch(err) {
  125. error(`Error while sending HEAD request to thumbnail URL for video '${watchId}' with quality '${quality}':`, err);
  126. void err;
  127. }
  128. if(response && response.status < 300 && response.status >= 200)
  129. return url;
  130. }
  131. }
  132. catch(err) {
  133. throw new Error(`Couldn't get thumbnail URL for video '${watchId}': ${err}`);
  134. }
  135. }
  136. /** Opens the given URL in a new tab, using GM.openInTab if available */
  137. export function openInTab(href: string, background = false) {
  138. try {
  139. openInNewTab(href, background);
  140. }
  141. catch {
  142. window.open(href, "_blank", "noopener noreferrer");
  143. }
  144. }
  145. /** Tries to parse an uncompressed or compressed input string as a JSON object */
  146. export async function tryToDecompressAndParse<TData = Record<string, unknown>>(input: StringGen): Promise<TData | null> {
  147. let parsed: TData | null = null;
  148. const val = await consumeStringGen(input);
  149. try {
  150. parsed = JSON.parse(val);
  151. }
  152. catch {
  153. try {
  154. parsed = JSON.parse(await decompress(val, compressionFormat, "string"));
  155. }
  156. catch(err) {
  157. error("Couldn't decompress and parse data due to an error:", err);
  158. return null;
  159. }
  160. }
  161. // artificial timeout to allow animations to finish and because dumb monkey brains *expect* a delay
  162. await pauseFor(randRange(250, 500));
  163. return parsed;
  164. }
  165. /** Very crude OS detection */
  166. export function getOS() {
  167. if(navigator.userAgent.match(/mac(\s?os|intel)/i))
  168. return "mac";
  169. return "other";
  170. }
  171. /** Formats a number based on the config or the passed {@linkcode notation} */
  172. export function formatNumber(num: number, notation?: NumberLengthFormat): string {
  173. return num.toLocaleString(
  174. getLocale().replace(/_/g, "-"),
  175. (notation ?? getFeature("numbersFormat")) === "short"
  176. ? {
  177. notation: "compact",
  178. compactDisplay: "short",
  179. maximumFractionDigits: 1,
  180. }
  181. : {
  182. style: "decimal",
  183. maximumFractionDigits: 0,
  184. },
  185. );
  186. }
  187. /** add `time_continue` param only if current video time is greater than this value */
  188. const reloadTabVideoTimeThreshold = 3;
  189. /** Reloads the tab. If a video is currently playing, its time and volume will be preserved through the URL parameter `time_continue` and `bytm-reload-tab-volume` in GM storage */
  190. export async function reloadTab() {
  191. const win = getUnsafeWindow();
  192. try {
  193. enableDiscardBeforeUnload();
  194. if((getVideoElement()?.readyState ?? 0) > 0) {
  195. const time = await getVideoTime(0) ?? 0;
  196. const volume = Math.round(getVideoElement()!.volume * 100);
  197. const url = new URL(win.location.href);
  198. if(!isNaN(time) && time > reloadTabVideoTimeThreshold)
  199. url.searchParams.set("time_continue", String(time));
  200. if(!isNaN(volume) && volume > 0)
  201. await GM.setValue("bytm-reload-tab-volume", String(volume));
  202. return win.location.replace(url);
  203. }
  204. win.location.reload();
  205. }
  206. catch(err) {
  207. error("Couldn't save video time and volume before reloading tab:", err);
  208. win.location.reload();
  209. }
  210. }
  211. /** Checks if the passed value is a {@linkcode StringGen} */
  212. export function isStringGen(val: unknown): val is StringGen {
  213. return typeof val === "string"
  214. || typeof val === "function"
  215. || (typeof val === "object" && val !== null && "toString" in val && !val.toString().startsWith("[object"))
  216. || val instanceof Promise;
  217. }
  218. //#region resources
  219. /**
  220. * Returns the blob-URL of a resource by its name, as defined in `assets/resources.json`, from GM resource cache - [see GM.getResourceUrl docs](https://wiki.greasespot.net/GM.getResourceUrl)
  221. * Falls back to a CDN URL or base64-encoded data URI if the resource is not available in the GM resource cache
  222. * @param name The name / key of the resource as defined in `assets/resources.json` - you can use `as "_"` to make TypeScript shut up if the name can not be typed as `ResourceKey`
  223. * @param uncached Set to true to always fetch from the CDN URL instead of the GM resource cache
  224. */
  225. export async function getResourceUrl(name: ResourceKey | "_", uncached = false) {
  226. let url = !uncached && await GM.getResourceUrl(name);
  227. if(!url || url.length === 0) {
  228. const resObjOrStr = resourcesJson.resources?.[name as keyof typeof resourcesJson.resources];
  229. if(typeof resObjOrStr === "object" || typeof resObjOrStr === "string") {
  230. const pathName = typeof resObjOrStr === "object" && "path" in resObjOrStr ? resObjOrStr?.path : resObjOrStr;
  231. const ghRef = typeof resObjOrStr === "object" && "ref" in resObjOrStr ? resObjOrStr?.ref : buildNumber;
  232. if(pathName) {
  233. return pathName.startsWith("http")
  234. ? pathName
  235. : (() => {
  236. let path = pathName;
  237. if(path.startsWith("/"))
  238. path = path.slice(1);
  239. else
  240. path = `assets/${path}`;
  241. switch(assetSource) {
  242. case "jsdelivr":
  243. return `https://cdn.jsdelivr.net/gh/${repo}@${ghRef}/${path}`;
  244. case "github":
  245. return `https://raw.githubusercontent.com/${repo}/${ghRef}/${path}`;
  246. case "local":
  247. return `http://localhost:${devServerPort}/${path}`;
  248. }
  249. })();
  250. }
  251. }
  252. warn(`Couldn't get blob URL nor external URL for @resource '${name}', attempting to use base64-encoded fallback`);
  253. // @ts-ignore
  254. url = await GM.getResourceUrl(name, false);
  255. }
  256. return url;
  257. }
  258. /**
  259. * Resolves the preferred locale of the user given their browser's language settings, as long as it is supported by the userscript directly or via the `altLocales` prop in `locales.json`
  260. * Prioritizes any supported value of `navigator.language`, then `navigator.languages`, then goes over them again, trimming off the part after the hyphen, then falls back to `"en-US"`
  261. */
  262. export function getPreferredLocale(): TrLocale {
  263. const sanEq = (str1: string, str2: string) => str1.trim().toLowerCase() === str2.trim().toLowerCase();
  264. const allNvLocs = [...new Set([navigator.language, ...navigator.languages])]
  265. .map((v) => v.replace(/_/g, "-"));
  266. for(const nvLoc of allNvLocs) {
  267. const resolvedLoc = Object.entries(langMapping)
  268. .find(([key, { altLocales }]) =>
  269. sanEq(key, nvLoc) || altLocales.find(al => sanEq(al, nvLoc))
  270. )?.[0];
  271. if(resolvedLoc)
  272. return resolvedLoc.trim() as TrLocale;
  273. const trimmedNvLoc = nvLoc.split("-")[0];
  274. const resolvedFallbackLoc = Object.entries(langMapping)
  275. .find(([key, { altLocales }]) =>
  276. sanEq(key.split("-")[0], trimmedNvLoc) || altLocales.find(al => sanEq(al.split("-")[0], trimmedNvLoc))
  277. )?.[0];
  278. if(resolvedFallbackLoc)
  279. return resolvedFallbackLoc.trim() as TrLocale;
  280. }
  281. return "en-US";
  282. }
  283. const resourceCache = new Map<string, string>();
  284. /**
  285. * Returns the content behind the passed resource identifier as a string, for example to be assigned to an element's innerHTML property.
  286. * Caches the resulting string if the resource key starts with `icon-`
  287. */
  288. export async function resourceAsString(resource: ResourceKey | "_") {
  289. if(resourceCache.has(resource))
  290. return resourceCache.get(resource)!;
  291. const resourceUrl = await getResourceUrl(resource);
  292. try {
  293. if(!resourceUrl)
  294. throw new Error(`Couldn't find URL for resource '${resource}'`);
  295. const str = await (await fetchAdvanced(resourceUrl)).text();
  296. // since SVG is lightweight, caching it in memory is fine
  297. if(resource.startsWith("icon-"))
  298. resourceCache.set(resource, str);
  299. return str;
  300. }
  301. catch(err) {
  302. error(`Couldn't fetch resource '${resource}' at URL '${resourceUrl}' due to an error:`, err);
  303. return null;
  304. }
  305. }
  306. /** Parses a markdown string using marked and turns it into an HTML string with default settings - doesn't sanitize against XSS! */
  307. export function parseMarkdown(mdString: string) {
  308. return marked.parse(mdString, {
  309. async: true,
  310. gfm: true,
  311. });
  312. }
  313. /** Returns the content of the changelog markdown file */
  314. export async function getChangelogMd() {
  315. const clRes = await fetchAdvanced(changelogUrl);
  316. log("Fetched changelog:", clRes);
  317. return await clRes.text();
  318. }
  319. /** Returns the changelog as HTML with a details element for each version */
  320. export async function getChangelogHtmlWithDetails() {
  321. try {
  322. const changelogMd = await getChangelogMd();
  323. let changelogHtml = await parseMarkdown(changelogMd);
  324. const getVerId = (verStr: string) => verStr.trim().replace(/[._#\s-]/g, "");
  325. changelogHtml = changelogHtml.replace(/<div\s+class="split">\s*<\/div>\s*\n?\s*<br(\s\/)?>/gm, "</details>\n<br>\n<details class=\"bytm-changelog-version-details\">");
  326. const h2Matches = Array.from(changelogHtml.matchAll(/<h2(\s+id=".+")?>([\d\w\s.]+)<\/h2>/gm));
  327. for(const [fullMatch, , verStr] of h2Matches)
  328. changelogHtml = changelogHtml.replace(fullMatch, `<summary tab-index="0"><h2 id="${getVerId(verStr)}" role="subheading" aria-level="1">${verStr}</h2></summary>`);
  329. changelogHtml = `<details class="bytm-changelog-version-details">${changelogHtml}</details>`;
  330. return changelogHtml;
  331. }
  332. catch(err) {
  333. return `Error while preparing changelog: ${err}`;
  334. }
  335. }