misc.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import { compress, decompress, fetchAdvanced, openInNewTab, pauseFor, randomId } from "@sv443-network/userutils";
  2. import { marked } from "marked";
  3. import { branch, compressionFormat, repo, sessionStorageAvailable } from "../constants.js";
  4. import { type Domain, type ResourceKey } from "../types.js";
  5. import { error, type TrLocale, warn, sendRequest } from "./index.js";
  6. import langMapping from "../../assets/locales.json" with { type: "json" };
  7. //#region misc
  8. let domain: Domain;
  9. /**
  10. * Returns the current domain as a constant string representation
  11. * @throws Throws if script runs on an unexpected website
  12. */
  13. export function getDomain(): Domain {
  14. if(domain)
  15. return domain;
  16. if(location.hostname.match(/^music\.youtube/))
  17. return domain = "ytm";
  18. else if(location.hostname.match(/youtube\./))
  19. return domain = "yt";
  20. else
  21. throw new Error("BetterYTM is running on an unexpected website. Please don't tamper with the @match directives in the userscript header.");
  22. }
  23. /** Returns a pseudo-random ID unique to each session - returns null if sessionStorage is unavailable */
  24. export function getSessionId(): string | null {
  25. try {
  26. if(!sessionStorageAvailable)
  27. throw new Error("Session storage unavailable");
  28. let sesId = window.sessionStorage.getItem("_bytm-session-id");
  29. if(!sesId)
  30. window.sessionStorage.setItem("_bytm-session-id", sesId = randomId(8, 36));
  31. return sesId;
  32. }
  33. catch(err) {
  34. warn("Couldn't get session ID, sessionStorage / cookies might be disabled:", err);
  35. return null;
  36. }
  37. }
  38. let isCompressionSupported: boolean | undefined;
  39. /** Tests whether compression via the predefined {@linkcode compressionFormat} is supported (only on the first call, then returns the cached result) */
  40. export async function compressionSupported() {
  41. if(typeof isCompressionSupported === "boolean")
  42. return isCompressionSupported;
  43. try {
  44. await compress(".", compressionFormat, "string");
  45. return isCompressionSupported = true;
  46. }
  47. catch {
  48. return isCompressionSupported = false;
  49. }
  50. }
  51. /** 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 */
  52. export function arrayWithSeparators<TArray>(array: TArray[], separator = ", ", lastSeparator?: string) {
  53. const arr = [...array];
  54. if(!lastSeparator)
  55. lastSeparator = separator;
  56. if(arr.length === 0)
  57. return "";
  58. else if(arr.length <= 2)
  59. return arr.join(lastSeparator);
  60. else
  61. return `${arr.slice(0, -1).join(separator)}${lastSeparator}${arr.at(-1)!}`;
  62. }
  63. /** Returns the watch ID of the current video or null if not on a video page */
  64. export function getWatchId() {
  65. const { searchParams, pathname } = new URL(location.href);
  66. return pathname.includes("/watch") ? searchParams.get("v") : null;
  67. }
  68. /**
  69. * 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`
  70. * Returns null if the current page is not a channel page or there was an error parsing the URL
  71. */
  72. export function getCurrentChannelId() {
  73. return parseChannelIdFromUrl(location.href);
  74. }
  75. /** Returns the channel ID from a URL or null if the URL is invalid */
  76. export function parseChannelIdFromUrl(url: string | URL) {
  77. try {
  78. const { pathname } = url instanceof URL ? url : new URL(url);
  79. if(pathname.includes("/channel/"))
  80. return sanitizeChannelId(pathname.split("/channel/")[1].split("/")[0]);
  81. else if(pathname.includes("/@"))
  82. return sanitizeChannelId(pathname.split("/@")[1].split("/")[0]);
  83. else
  84. return null;
  85. }
  86. catch {
  87. return null;
  88. }
  89. }
  90. /** Sanitizes a channel ID by adding a leading `@` if the ID doesn't start with `UC...` */
  91. export function sanitizeChannelId(channelId: string) {
  92. channelId = String(channelId).trim();
  93. return isValidChannelId(channelId)
  94. ? channelId
  95. : `@${channelId}`;
  96. }
  97. /** Tests whether a string is a valid channel ID in the format `@User` or `.C...` */
  98. export function isValidChannelId(channelId: string) {
  99. return channelId.match(/^([A-Z]C|@)\w+$/) !== null;
  100. }
  101. /** Quality identifier for a thumbnail - from highest to lowest res: `maxresdefault` > `sddefault` > `hqdefault` > `mqdefault` > `default` */
  102. type ThumbQuality = `${"maxres" | "sd" | "hq" | "mq" | ""}default`;
  103. /** Returns the thumbnail URL for a video with the given watch ID and quality (defaults to "hqdefault") */
  104. export function getThumbnailUrl(watchId: string, quality?: ThumbQuality): string
  105. /** 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) */
  106. export function getThumbnailUrl(watchId: string, index: 0 | 1 | 2 | 3): string
  107. /** Returns the thumbnail URL for a video with either a given quality identifier or index */
  108. export function getThumbnailUrl(watchId: string, qualityOrIndex: ThumbQuality | 0 | 1 | 2 | 3 = "maxresdefault") {
  109. return `https://i.ytimg.com/vi/${watchId}/${qualityOrIndex}.jpg`;
  110. }
  111. /** Returns the best available thumbnail URL for a video with the given watch ID */
  112. export async function getBestThumbnailUrl(watchId: string) {
  113. try {
  114. const priorityList = ["maxresdefault", "sddefault", "hqdefault", 0];
  115. for(const quality of priorityList) {
  116. let response: GM.Response<unknown> | undefined;
  117. const url = getThumbnailUrl(watchId, quality as ThumbQuality);
  118. try {
  119. response = await sendRequest({ url, method: "HEAD", timeout: 6_000 });
  120. }
  121. catch(err) {
  122. error(`Error while sending HEAD request to thumbnail URL for video '${watchId}' with quality '${quality}':`, err);
  123. void err;
  124. }
  125. if(response && response.status < 300 && response.status >= 200)
  126. return url;
  127. }
  128. }
  129. catch(err) {
  130. throw new Error(`Couldn't get thumbnail URL for video '${watchId}': ${err}`);
  131. }
  132. }
  133. /** Opens the given URL in a new tab, using GM.openInTab if available */
  134. export function openInTab(href: string, background = false) {
  135. try {
  136. openInNewTab(href, background);
  137. }
  138. catch {
  139. window.open(href, "_blank", "noopener noreferrer");
  140. }
  141. }
  142. /** Tries to parse an uncompressed or compressed input string as a JSON object */
  143. export async function tryToDecompressAndParse<TData = Record<string, unknown>>(input: string): Promise<TData | null> {
  144. let parsed: TData | null = null;
  145. try {
  146. parsed = JSON.parse(input);
  147. }
  148. catch {
  149. try {
  150. parsed = JSON.parse(await decompress(input, compressionFormat, "string"));
  151. }
  152. catch(err) {
  153. error("Couldn't decompress and parse data due to an error:", err);
  154. return null;
  155. }
  156. }
  157. // artificial timeout to allow animations to finish and because dumb monkey brains *expect* a delay
  158. await pauseFor(250);
  159. return parsed;
  160. }
  161. //#region resources
  162. /**
  163. * Returns the 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)
  164. * Falls back to a `raw.githubusercontent.com` URL or base64-encoded data URI if the resource is not available in the GM resource cache
  165. */
  166. export async function getResourceUrl(name: ResourceKey | "_") {
  167. let url = await GM.getResourceUrl(name);
  168. if(!url || url.length === 0) {
  169. const resource = GM.info.script.resources?.[name].url;
  170. if(typeof resource === "string") {
  171. const resourceUrl = new URL(resource);
  172. const resourcePath = resourceUrl.pathname;
  173. if(resourcePath)
  174. return `https://raw.githubusercontent.com/${repo}/${branch}${resourcePath}`;
  175. }
  176. warn(`Couldn't get blob URL nor external URL for @resource '${name}', trying to use base64-encoded fallback`);
  177. // @ts-ignore
  178. url = await GM.getResourceUrl(name, false);
  179. }
  180. return url;
  181. }
  182. /**
  183. * Returns the preferred locale of the user, provided it is supported by the userscript.
  184. * Prioritizes `navigator.language`, then `navigator.languages`, then `"en_US"` as a fallback.
  185. */
  186. export function getPreferredLocale(): TrLocale {
  187. const navLang = navigator.language.replace(/-/g, "_");
  188. const navLangs = navigator.languages
  189. .filter(lang => lang.match(/^[a-z]{2}(-|_)[A-Z]$/) !== null)
  190. .map(lang => lang.replace(/-/g, "_"));
  191. if(Object.entries(langMapping).find(([key]) => key === navLang))
  192. return navLang as TrLocale;
  193. for(const loc of navLangs) {
  194. if(Object.entries(langMapping).find(([key]) => key === loc))
  195. return loc as TrLocale;
  196. }
  197. // if navigator.languages has entries that aren't locale codes in the format xx_XX
  198. if(navigator.languages.some(lang => lang.match(/^[a-z]{2}$/))) {
  199. for(const lang of navLangs) {
  200. const foundLoc = Object.entries(langMapping).find(([ key ]) => key.startsWith(lang))?.[0];
  201. if(foundLoc)
  202. return foundLoc as TrLocale;
  203. }
  204. }
  205. return "en_US";
  206. }
  207. /** Returns the content behind the passed resource identifier as a string, for example to be assigned to an element's innerHTML property */
  208. export async function resourceAsString(resource: ResourceKey | "_") {
  209. try {
  210. const resourceUrl = await getResourceUrl(resource);
  211. if(!resourceUrl)
  212. throw new Error(`Couldn't find URL for resource '${resource}'`);
  213. return await (await fetchAdvanced(resourceUrl)).text();
  214. }
  215. catch(err) {
  216. error("Couldn't get SVG element from resource:", err);
  217. return null;
  218. }
  219. }
  220. /** Parses a markdown string using marked and turns it into an HTML string with default settings - doesn't sanitize against XSS! */
  221. export function parseMarkdown(mdString: string) {
  222. return marked.parse(mdString, {
  223. async: true,
  224. gfm: true,
  225. });
  226. }
  227. /** Returns the content of the changelog markdown file */
  228. export async function getChangelogMd() {
  229. return await (await fetchAdvanced(await getResourceUrl("doc-changelog"))).text();
  230. }
  231. /** Returns the changelog as HTML with a details element for each version */
  232. export async function getChangelogHtmlWithDetails() {
  233. try {
  234. const changelogMd = await getChangelogMd();
  235. let changelogHtml = await parseMarkdown(changelogMd);
  236. const getVerId = (verStr: string) => verStr.trim().replace(/[._#\s-]/g, "");
  237. 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\" tabindex=\"0\">");
  238. const h2Matches = Array.from(changelogHtml.matchAll(/<h2(\s+id=".+")?>([\d\w\s.]+)<\/h2>/gm));
  239. for(const match of h2Matches) {
  240. const [fullMatch, , verStr] = match;
  241. const verId = getVerId(verStr);
  242. const h2Elem = `<h2 id="${verId}" role="subheading" aria-level="1">${verStr}</h2>`;
  243. const summaryElem = `<summary tab-index="0">${h2Elem}</summary>`;
  244. changelogHtml = changelogHtml.replace(fullMatch, `${summaryElem}`);
  245. }
  246. changelogHtml = `<details class="bytm-changelog-version-details" tabindex="0">${changelogHtml}</details>`;
  247. return changelogHtml;
  248. }
  249. catch(err) {
  250. return `Error while preparing changelog: ${err}`;
  251. }
  252. }