lyrics.ts 9.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  1. import { triesInterval, triesLimit } from "../constants";
  2. import { clamp, error, getAssetUrl, info, insertAfter, log } from "../utils";
  3. /** Base URL of geniURL */
  4. export const geniUrlBase = "https://api.sv443.net/geniurl";
  5. /** GeniURL endpoint that gives song metadata when provided with a `?q` or `?artist` and `?song` parameter - [more info](https://api.sv443.net/geniurl) */
  6. const geniURLSearchTopUrl = `${geniUrlBase}/search/top`;
  7. /**
  8. * The threshold to pass to geniURL's fuzzy filtering.
  9. * From fuse.js docs: At what point does the match algorithm give up. A threshold of 0.0 requires a perfect match (of both letters and location), a threshold of 1.0 would match anything.
  10. * Set to undefined to use the default.
  11. */
  12. const threshold = 0.55;
  13. /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  14. const geniUrlRatelimitTimeframe = 30;
  15. const thresholdParam = threshold ? `&threshold=${clamp(threshold, 0, 1)}` : "";
  16. //#MARKER cache
  17. /** Cache with key format `ARTIST - SONG` (sanitized) and lyrics URLs as values. Used to prevent extraneous requests to geniURL. */
  18. const lyricsUrlCache = new Map<string, string>();
  19. /** How many cache entries can exist at a time - this is used to cap memory usage */
  20. const maxLyricsCacheSize = 100;
  21. /**
  22. * Returns the lyrics URL from the passed un-/sanitized artist and song name, or undefined if the entry doesn't exist yet.
  23. * **The passed parameters need to be sanitized first!**
  24. */
  25. export function getLyricsCacheEntry(artists: string, song: string) {
  26. return lyricsUrlCache.get(`${artists} - ${song}`);
  27. }
  28. /** Adds the provided entry into the lyrics URL cache */
  29. export function addLyricsCacheEntry(artists: string, song: string, lyricsUrl: string) {
  30. lyricsUrlCache.set(`${sanitizeArtists(artists)} - ${sanitizeSong(song)}`, lyricsUrl);
  31. // delete oldest entry if cache gets too big
  32. if(lyricsUrlCache.size > maxLyricsCacheSize)
  33. lyricsUrlCache.delete([...lyricsUrlCache.keys()].at(-1)!);
  34. }
  35. //#MARKER media control bar
  36. let mcCurrentSongTitle = "";
  37. let mcLyricsButtonAddTries = 0;
  38. /** Adds a lyrics button to the media controls bar */
  39. export function addMediaCtrlLyricsBtn(): void {
  40. const likeContainer = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer") as HTMLElement;
  41. if(!likeContainer) {
  42. mcLyricsButtonAddTries++;
  43. if(mcLyricsButtonAddTries < triesLimit) {
  44. setTimeout(addMediaCtrlLyricsBtn, triesInterval); // TODO: improve this
  45. return;
  46. }
  47. return error(`Couldn't find element to append lyrics buttons to after ${mcLyricsButtonAddTries} tries`);
  48. }
  49. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLDivElement;
  50. // run parallel without awaiting so the MutationObserver below can observe the title element in time
  51. (async () => {
  52. const gUrl = await getCurrentLyricsUrl();
  53. const linkElem = createLyricsBtn(gUrl ?? undefined);
  54. linkElem.id = "betterytm-lyrics-button";
  55. log(`Inserted lyrics button after ${mcLyricsButtonAddTries} tries:`, linkElem);
  56. insertAfter(likeContainer, linkElem);
  57. })();
  58. mcCurrentSongTitle = songTitleElem.title;
  59. const onMutation = async (mutations: MutationRecord[]) => {
  60. for await(const mut of mutations) {
  61. const newTitle = (mut.target as HTMLElement).title;
  62. if(newTitle !== mcCurrentSongTitle && newTitle.length > 0) {
  63. const lyricsBtn = document.querySelector("#betterytm-lyrics-button") as HTMLAnchorElement;
  64. if(!lyricsBtn)
  65. return;
  66. log(`Song title changed from '${mcCurrentSongTitle}' to '${newTitle}'`);
  67. lyricsBtn.style.cursor = "wait";
  68. lyricsBtn.style.pointerEvents = "none";
  69. mcCurrentSongTitle = newTitle;
  70. const url = await getCurrentLyricsUrl(); // can take a second or two
  71. if(!url)
  72. continue;
  73. lyricsBtn.href = url;
  74. lyricsBtn.title = "Open the current song's lyrics in a new tab";
  75. lyricsBtn.style.cursor = "pointer";
  76. lyricsBtn.style.visibility = "initial";
  77. lyricsBtn.style.display = "inline-flex";
  78. lyricsBtn.style.pointerEvents = "initial";
  79. }
  80. }
  81. };
  82. // 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
  83. const obs = new MutationObserver(onMutation);
  84. obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] });
  85. }
  86. //#MARKER utils
  87. /** Removes everything in parentheses from the passed song name */
  88. export function sanitizeSong(songName: string) {
  89. const parensRegex = /\(.+\)/gmi;
  90. const squareParensRegex = /\[.+\]/gmi;
  91. // trim right after the song name:
  92. const sanitized = songName
  93. .replace(parensRegex, "")
  94. .replace(squareParensRegex, "");
  95. return sanitized.trim();
  96. }
  97. /** Removes the secondary artist (if it exists) from the passed artists string */
  98. export function sanitizeArtists(artists: string) {
  99. artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
  100. if(artists.match(/&/))
  101. artists = artists.split(/\s*&\s*/gm)[0];
  102. if(artists.match(/,/))
  103. artists = artists.split(/,\s*/gm)[0];
  104. return artists.trim();
  105. }
  106. /** Returns the lyrics URL from genius for the currently selected song */
  107. export async function getCurrentLyricsUrl() {
  108. try {
  109. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  110. const isVideo = typeof document.querySelector("ytmusic-player")?.getAttribute("video-mode_") === "string";
  111. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLElement;
  112. const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child") as HTMLElement;
  113. if(!songTitleElem || !songMetaElem || !songTitleElem.title)
  114. return null;
  115. const songNameRaw = songTitleElem.title;
  116. const songName = sanitizeSong(songNameRaw);
  117. const artistName = sanitizeArtists(songMetaElem.title);
  118. /** Use when the current song is not a "real YTM song" with a static background, but rather a music video */
  119. const getGeniusUrlVideo = async () => {
  120. if(!songName.includes("-")) // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  121. return await getGeniusUrl(artistName, songName);
  122. const [artist, ...rest] = songName.split("-").map(v => v.trim());
  123. return await getGeniusUrl(artist, rest.join(" "));
  124. };
  125. // TODO: artist might need further splitting before comma or ampersand
  126. const url = isVideo ? await getGeniusUrlVideo() : await getGeniusUrl(artistName, songName);
  127. return url;
  128. }
  129. catch(err) {
  130. error("Couldn't resolve lyrics URL:", err);
  131. return null;
  132. }
  133. }
  134. /** Fetches the actual lyrics URL from geniURL - **the passed parameters need to be sanitized first!** */
  135. export async function getGeniusUrl(artist: string, song: string): Promise<string | undefined> {
  136. try {
  137. const cacheEntry = getLyricsCacheEntry(artist, song);
  138. if(cacheEntry) {
  139. info(`Found lyrics URL in cache: ${cacheEntry}`);
  140. return cacheEntry;
  141. }
  142. const startTs = Date.now();
  143. const fetchUrl = `${geniURLSearchTopUrl}?artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}${thresholdParam}`;
  144. log(`Requesting URL from geniURL at '${fetchUrl}'`);
  145. const fetchRes = await fetch(fetchUrl);
  146. if(fetchRes.status === 429) {
  147. alert(`You are being rate limited.\nPlease wait ${fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe} seconds before requesting more lyrics.`);
  148. return undefined;
  149. }
  150. else if(fetchRes.status < 200 || fetchRes.status >= 300) {
  151. error(`Couldn't fetch lyrics URL from geniURL - status: ${fetchRes.status} - response: ${(await fetchRes.json()).message ?? await fetchRes.text() ?? "(none)"}`);
  152. return undefined;
  153. }
  154. const result = await fetchRes.json();
  155. if(typeof result === "object" && result.error) {
  156. error("Couldn't fetch lyrics URL:", result.message);
  157. return undefined;
  158. }
  159. const url = result.url;
  160. info(`Found lyrics URL (after ${Date.now() - startTs}ms): ${url}`);
  161. addLyricsCacheEntry(artist, song, url);
  162. return url;
  163. }
  164. catch(err) {
  165. error("Couldn't get lyrics URL due to error:", err);
  166. return undefined;
  167. }
  168. }
  169. /** Creates the base lyrics button element */
  170. export function createLyricsBtn(geniusUrl?: string, hideIfLoading = true): HTMLAnchorElement {
  171. const linkElem = document.createElement("a");
  172. linkElem.className = "ytmusic-player-bar bytm-generic-btn";
  173. linkElem.title = geniusUrl ? "Click to open this song's lyrics in a new tab" : "Loading lyrics URL...";
  174. if(geniusUrl)
  175. linkElem.href = geniusUrl;
  176. linkElem.role = "button";
  177. linkElem.target = "_blank";
  178. linkElem.rel = "noopener noreferrer";
  179. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  180. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  181. const imgElem = document.createElement("img");
  182. imgElem.className = "bytm-generic-btn-img";
  183. imgElem.src = getAssetUrl("external/genius.png");
  184. linkElem.appendChild(imgElem);
  185. return linkElem;
  186. }