lyrics.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. import { fetchAdvanced } from "@sv443-network/userutils";
  2. import { error, getResourceUrl, info, log, warn, t, tp, getCurrentMediaType, constructUrl, onInteraction, openInTab } from "../utils/index.js";
  3. import { emitInterface } from "../interface.js";
  4. import { mode, scriptInfo } from "../constants.js";
  5. import { getFeature } from "../config.js";
  6. import { addLyricsCacheEntryBest, getLyricsCacheEntry } from "./lyricsCache.js";
  7. import type { LyricsCacheEntry } from "../types.js";
  8. import { addSelectorListener } from "../observers.js";
  9. /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  10. const geniUrlRatelimitTimeframe = 30;
  11. //#region media control bar
  12. let currentSongTitle = "";
  13. /** Adds a lyrics button to the player bar */
  14. export async function addPlayerBarLyricsBtn() {
  15. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn });
  16. }
  17. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  18. async function addActualLyricsBtn(likeContainer: HTMLElement) {
  19. const songTitleElem = document.querySelector<HTMLDivElement>(".content-info-wrapper > yt-formatted-string");
  20. if(!songTitleElem)
  21. return warn("Couldn't find song title element");
  22. currentSongTitle = songTitleElem.title;
  23. const spinnerIconUrl = await getResourceUrl("icon-spinner");
  24. const lyricsIconUrl = await getResourceUrl("icon-lyrics");
  25. const errorIconUrl = await getResourceUrl("icon-error");
  26. const onMutation = async (mutations: MutationRecord[]) => {
  27. for await(const mut of mutations) {
  28. const newTitle = (mut.target as HTMLElement).title;
  29. if(newTitle !== currentSongTitle && newTitle.length > 0) {
  30. const lyricsBtn = document.querySelector<HTMLAnchorElement>("#bytm-player-bar-lyrics-btn");
  31. if(!lyricsBtn)
  32. continue;
  33. lyricsBtn.style.cursor = "wait";
  34. lyricsBtn.style.pointerEvents = "none";
  35. const imgElem = lyricsBtn.querySelector<HTMLImageElement>("img")!;
  36. imgElem.src = spinnerIconUrl;
  37. imgElem.classList.add("bytm-spinner");
  38. currentSongTitle = newTitle;
  39. const url = await getCurrentLyricsUrl(); // can take a second or two
  40. imgElem.src = lyricsIconUrl;
  41. imgElem.classList.remove("bytm-spinner");
  42. if(!url) {
  43. let artist, song;
  44. if("mediaSession" in navigator && navigator.mediaSession.metadata) {
  45. artist = navigator.mediaSession.metadata.artist;
  46. song = navigator.mediaSession.metadata.title;
  47. }
  48. const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
  49. imgElem.src = errorIconUrl;
  50. lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_search");
  51. lyricsBtn.style.cursor = "pointer";
  52. lyricsBtn.style.pointerEvents = "all";
  53. lyricsBtn.style.display = "inline-flex";
  54. lyricsBtn.style.visibility = "visible";
  55. lyricsBtn.href = `https://genius.com/search${query}`;
  56. continue;
  57. }
  58. lyricsBtn.href = url;
  59. lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
  60. lyricsBtn.style.cursor = "pointer";
  61. lyricsBtn.style.visibility = "visible";
  62. lyricsBtn.style.display = "inline-flex";
  63. lyricsBtn.style.pointerEvents = "initial";
  64. }
  65. }
  66. };
  67. // since YT and YTM don't reload the page on video change, MutationObserver needs to be used to watch for changes in the video title
  68. const obs = new MutationObserver(onMutation);
  69. obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] });
  70. const lyricsBtnElem = await createLyricsBtn(undefined);
  71. lyricsBtnElem.id = "bytm-player-bar-lyrics-btn";
  72. // run parallel so the element is inserted as soon as possible
  73. getCurrentLyricsUrl().then(url => {
  74. url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url);
  75. });
  76. log("Inserted lyrics button into media controls bar");
  77. const thumbToggleElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay-toggle");
  78. if(thumbToggleElem)
  79. thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem);
  80. else
  81. likeContainer.insertAdjacentElement("afterend", lyricsBtnElem);
  82. }
  83. //#region lyrics utils
  84. /** Removes everything in parentheses from the passed song name */
  85. export function sanitizeSong(songName: string) {
  86. if(typeof songName !== "string")
  87. return songName;
  88. const parensRegex = /\(.+\)/gmi;
  89. const squareParensRegex = /\[.+\]/gmi;
  90. // trim right after the song name:
  91. const sanitized = songName
  92. .replace(parensRegex, "")
  93. .replace(squareParensRegex, "");
  94. return sanitized.trim();
  95. }
  96. /** Removes the secondary artist (if it exists) from the passed artists string */
  97. export function sanitizeArtists(artists: string) {
  98. artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
  99. if(artists.match(/&/))
  100. artists = artists.split(/\s*&\s*/gm)[0];
  101. if(artists.match(/,/))
  102. artists = artists.split(/,\s*/gm)[0];
  103. if(artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) {
  104. const parensRegex = /\(.+\)/gmi;
  105. const squareParensRegex = /\[.+\]/gmi;
  106. artists = artists
  107. .replace(parensRegex, "")
  108. .replace(squareParensRegex, "");
  109. }
  110. return artists.trim();
  111. }
  112. /** Returns the lyrics URL from genius for the currently selected song */
  113. export async function getCurrentLyricsUrl() {
  114. try {
  115. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  116. const isVideo = getCurrentMediaType() === "video";
  117. const songTitleElem = document.querySelector<HTMLElement>(".content-info-wrapper > yt-formatted-string");
  118. const songMetaElem = document.querySelector<HTMLElement>("span.subtitle > yt-formatted-string :first-child");
  119. if(!songTitleElem || !songMetaElem)
  120. return undefined;
  121. const songNameRaw = songTitleElem.title;
  122. let songName = songNameRaw;
  123. let artistName = songMetaElem.textContent;
  124. if(isVideo) {
  125. // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  126. if(songName.includes("-")) {
  127. const split = splitVideoTitle(songName);
  128. songName = split.song;
  129. artistName = split.artist;
  130. }
  131. }
  132. if(!artistName)
  133. return undefined;
  134. const url = await fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName));
  135. if(url) {
  136. emitInterface("bytm:lyricsLoaded", {
  137. type: "current",
  138. artists: artistName,
  139. title: songName,
  140. url,
  141. });
  142. }
  143. return url;
  144. }
  145. catch(err) {
  146. error("Couldn't resolve lyrics URL:", err);
  147. return undefined;
  148. }
  149. }
  150. /** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */
  151. export async function fetchLyricsUrlTop(artist: string, song: string): Promise<string | undefined> {
  152. try {
  153. return (await fetchLyricsUrls(artist, song))?.[0]?.url;
  154. }
  155. catch(err) {
  156. error("Couldn't get lyrics URL due to error:", err);
  157. return undefined;
  158. }
  159. }
  160. /**
  161. * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search
  162. * **the passed parameters need to be sanitized first!**
  163. */
  164. export async function fetchLyricsUrls(artist: string, song: string): Promise<Omit<LyricsCacheEntry, "added" | "viewed">[] | undefined> {
  165. try {
  166. const cacheEntry = getLyricsCacheEntry(artist, song);
  167. if(cacheEntry) {
  168. info(`Found lyrics URL in cache: ${cacheEntry.url}`);
  169. return [cacheEntry];
  170. }
  171. const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, {
  172. disableFuzzy: null,
  173. utm_source: `${scriptInfo.name} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`,
  174. q: `${artist} ${song}`,
  175. });
  176. log("Requesting lyrics from geniURL:", fetchUrl);
  177. const token = getFeature("geniUrlToken");
  178. const fetchRes = await fetchAdvanced(fetchUrl, {
  179. ...(token ? {
  180. headers: {
  181. Authorization: `Bearer ${token}`,
  182. },
  183. } : {}),
  184. });
  185. if(fetchRes.status === 429) {
  186. const waitSeconds = Number(fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe);
  187. alert(tp("lyrics_rate_limited", waitSeconds, waitSeconds));
  188. return undefined;
  189. }
  190. else if(fetchRes.status < 200 || fetchRes.status >= 300) {
  191. error(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(await fetchRes.json()).message ?? await fetchRes.text() ?? "(none)"}`);
  192. return undefined;
  193. }
  194. const result = await fetchRes.json();
  195. if(typeof result === "object" && result.error || !result || !result.all) {
  196. error("Couldn't fetch lyrics URL:", result.message);
  197. return undefined;
  198. }
  199. const allResults = result.all as {
  200. url: string;
  201. meta: {
  202. title: string;
  203. fullTitle: string;
  204. artists: string;
  205. primaryArtist: {
  206. name: string;
  207. };
  208. };
  209. }[];
  210. if(allResults.length === 0) {
  211. warn("No lyrics URL found for the provided song");
  212. return undefined;
  213. }
  214. const allResultsSan = allResults
  215. .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url)
  216. .map(({ meta, url }) => ({
  217. meta: {
  218. ...meta,
  219. title: sanitizeSong(String(meta.title ?? meta.fullTitle)),
  220. artists: sanitizeArtists(String(meta.artists)),
  221. },
  222. url,
  223. }));
  224. const topRes = allResultsSan[0];
  225. topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url);
  226. return allResultsSan.map(r => ({
  227. artist: r.meta.primaryArtist.name,
  228. song: r.meta.title,
  229. url: r.url,
  230. }));
  231. }
  232. catch(err) {
  233. error("Couldn't get lyrics URL due to error:", err);
  234. return undefined;
  235. }
  236. }
  237. /** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */
  238. export async function addGeniusUrlToLyricsBtn(btnElem: HTMLAnchorElement, geniusUrl: string) {
  239. btnElem.href = geniusUrl;
  240. btnElem.ariaLabel = btnElem.title = t("open_lyrics");
  241. btnElem.style.visibility = "visible";
  242. btnElem.style.display = "inline-flex";
  243. }
  244. /** Creates the base lyrics button element */
  245. export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) {
  246. const linkElem = document.createElement("a");
  247. linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
  248. linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
  249. if(geniusUrl)
  250. linkElem.href = geniusUrl;
  251. linkElem.role = "button";
  252. linkElem.target = "_blank";
  253. linkElem.rel = "noopener noreferrer";
  254. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  255. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  256. const imgElem = document.createElement("img");
  257. imgElem.classList.add("bytm-generic-btn-img");
  258. imgElem.src = await getResourceUrl("icon-lyrics");
  259. onInteraction(linkElem, (e) => {
  260. const url = linkElem.href ?? geniusUrl;
  261. if(!url || e instanceof MouseEvent)
  262. return;
  263. openInTab(url);
  264. }, {
  265. preventDefault: false,
  266. stopPropagation: false,
  267. });
  268. linkElem.appendChild(imgElem);
  269. onInteraction(linkElem, async (e) => {
  270. if(e.ctrlKey || e.altKey) {
  271. e.preventDefault();
  272. e.stopPropagation();
  273. const search = prompt(t("open_lyrics_search_prompt"));
  274. if(search && search.length > 0)
  275. openInTab(`https://genius.com/search?q=${encodeURIComponent(search)}`);
  276. }
  277. }, {
  278. preventDefault: false,
  279. stopPropagation: false,
  280. });
  281. return linkElem;
  282. }
  283. /** Splits a video title that contains a hyphen into an artist and song */
  284. export function splitVideoTitle(title: string) {
  285. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  286. return { artist, song: rest.join("-") };
  287. }