lyrics.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. import { triesInterval, triesLimit } from "../constants";
  2. import { error, info, insertAfter, log } from "../utils";
  3. import "./lyrics.css";
  4. /** Base URL of geniURL */
  5. export const geniUrlBase = "https://api.sv443.net/geniurl";
  6. /** GeniURL endpoint that gives song metadata when provided with a `?q` or `?artist` and `?song` parameter - [more info](https://api.sv443.net/geniurl) */
  7. const geniURLSearchTopUrl = `${geniUrlBase}/search/top`;
  8. let mcCurrentSongTitle = "";
  9. let mcLyricsButtonAddTries = 0;
  10. /** Adds a genius.com lyrics button to the media controls bar */
  11. export async function addMediaCtrlGeniusBtn(): Promise<unknown> {
  12. const likeContainer = document.querySelector(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer") as HTMLElement;
  13. if(!likeContainer) {
  14. mcLyricsButtonAddTries++;
  15. if(mcLyricsButtonAddTries < triesLimit)
  16. return setTimeout(addMediaCtrlGeniusBtn, triesInterval); // TODO: improve this
  17. return console.error(`Couldn't find element to append lyrics buttons to after ${mcLyricsButtonAddTries} tries`);
  18. }
  19. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLDivElement;
  20. const gUrl = await getCurrentGeniusUrl();
  21. const linkElem = document.createElement("a");
  22. linkElem.id = "betterytm-lyrics-button";
  23. linkElem.className = "ytmusic-player-bar";
  24. linkElem.title = gUrl ? "Click to open this song's lyrics in a new tab" : "Loading...";
  25. if(gUrl)
  26. linkElem.href = gUrl;
  27. linkElem.target = "_blank";
  28. linkElem.rel = "noopener noreferrer";
  29. linkElem.style.visibility = gUrl ? "initial" : "hidden";
  30. linkElem.style.display = gUrl ? "inline-flex" : "none";
  31. const imgElem = document.createElement("img");
  32. imgElem.id = "betterytm-lyrics-img";
  33. imgElem.src = "https://raw.githubusercontent.com/Sv443/BetterYTM/main/resources/external/genius.png";
  34. linkElem.appendChild(imgElem);
  35. log(`Inserted genius button after ${mcLyricsButtonAddTries} tries:`, linkElem);
  36. insertAfter(likeContainer, linkElem);
  37. mcCurrentSongTitle = songTitleElem.title;
  38. const onMutation = async (mutations: MutationRecord[]) => {
  39. for await(const mut of mutations) {
  40. const newTitle = (mut.target as HTMLElement).title;
  41. if(newTitle != mcCurrentSongTitle && newTitle.length > 0) {
  42. const lyricsBtn = document.querySelector("#betterytm-lyrics-button") as HTMLAnchorElement;
  43. if(!lyricsBtn)
  44. return;
  45. log(`Song title changed from '${mcCurrentSongTitle}' to '${newTitle}'`);
  46. lyricsBtn.style.cursor = "wait";
  47. lyricsBtn.style.pointerEvents = "none";
  48. mcCurrentSongTitle = newTitle;
  49. const url = await getCurrentGeniusUrl(); // can take a second or two
  50. if(!url)
  51. continue;
  52. lyricsBtn.href = url;
  53. lyricsBtn.title = "Click to open this song's lyrics in a new tab";
  54. lyricsBtn.style.cursor = "pointer";
  55. lyricsBtn.style.visibility = "initial";
  56. lyricsBtn.style.display = "inline-flex";
  57. lyricsBtn.style.pointerEvents = "initial";
  58. }
  59. }
  60. };
  61. // 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
  62. const obs = new MutationObserver(onMutation);
  63. obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] });
  64. }
  65. /** Returns the genius.com lyrics site URL for the current song */
  66. export async function getCurrentGeniusUrl() {
  67. try {
  68. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  69. const isVideo = typeof document.querySelector("ytmusic-player")?.getAttribute("video-mode_") === "string";
  70. const songTitleElem = document.querySelector(".content-info-wrapper > yt-formatted-string") as HTMLElement;
  71. const songMetaElem = document.querySelector("span.subtitle > yt-formatted-string:first-child") as HTMLElement;
  72. if(!songTitleElem || !songMetaElem || !songTitleElem.title)
  73. return null;
  74. const sanitizeSongName = (songName: string) => {
  75. const parensRegex = /\(.+\)/gmi;
  76. const squareParensRegex = /\[.+\]/gmi;
  77. // trim right after the song name:
  78. const sanitized = songName
  79. .replace(parensRegex, "")
  80. .replace(squareParensRegex, "");
  81. return sanitized.trim();
  82. };
  83. const splitArtist = (songMeta: string) => {
  84. songMeta = songMeta.split(/\s*\u2022\s*/gmiu)[0]; // split at bullet (&bull; / •) character
  85. if(songMeta.match(/&/))
  86. songMeta = songMeta.split(/\s*&\s*/gm)[0];
  87. if(songMeta.match(/,/))
  88. songMeta = songMeta.split(/,\s*/gm)[0];
  89. return songMeta.trim();
  90. };
  91. const songNameRaw = songTitleElem.title;
  92. const songName = sanitizeSongName(songNameRaw);
  93. const artistName = splitArtist(songMetaElem.title);
  94. /** Use when the current song is not a "real YTM song" with a static background, but rather a music video */
  95. const getGeniusUrlVideo = async () => {
  96. if(!songName.includes("-")) // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  97. return await getGeniusUrl(artistName, songName);
  98. const [artist, ...rest] = songName.split("-").map(v => v.trim());
  99. return await getGeniusUrl(artist, rest.join(" "));
  100. };
  101. // TODO: artist might need further splitting before comma or ampersand
  102. const url = isVideo ? await getGeniusUrlVideo() : (await getGeniusUrl(artistName, songName) ?? await getGeniusUrlVideo());
  103. return url;
  104. }
  105. catch(err)
  106. {
  107. error("Couldn't resolve genius.com URL:", err);
  108. return null;
  109. }
  110. }
  111. /**
  112. * @param artist
  113. * @param song
  114. */
  115. async function getGeniusUrl(artist: string, song: string): Promise<string | undefined> {
  116. try {
  117. const fetchUrl = `${geniURLSearchTopUrl}?artist=${encodeURIComponent(artist)}&song=${encodeURIComponent(song)}`;
  118. log(`Requesting URL from geniURL at '${fetchUrl}'`);
  119. const result = await (await fetch(fetchUrl)).json();
  120. if(result.error)
  121. {
  122. error("Couldn't fetch genius.com URL:", result.message);
  123. return undefined;
  124. }
  125. const url = result.url;
  126. info(`Found genius URL: ${url}`);
  127. return url;
  128. }
  129. catch(err) {
  130. error("Couldn't get genius URL due to error:", err);
  131. return undefined;
  132. }
  133. }