lyrics.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412
  1. import { autoPlural, fetchAdvanced } from "@sv443-network/userutils";
  2. import Fuse from "fuse.js";
  3. import { error, getResourceUrl, info, log, warn, t, tp, currentMediaType, constructUrl } from "../utils";
  4. import { emitInterface } from "../interface";
  5. import { mode, scriptInfo } from "../constants";
  6. import { getFeature } from "../config";
  7. import { addLyricsCacheEntryBest, addLyricsCacheEntryPenalized, getLyricsCacheEntry } from "./lyricsCache";
  8. import type { LyricsCacheEntry } from "../types";
  9. import { addSelectorListener } from "src/observers";
  10. /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  11. const geniUrlRatelimitTimeframe = 30;
  12. //#region media control bar
  13. let currentSongTitle = "";
  14. /** Adds a lyrics button to the media controls bar */
  15. export async function addMediaCtrlLyricsBtn() {
  16. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn });
  17. }
  18. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  19. async function addActualMediaCtrlLyricsBtn(likeContainer: HTMLElement) {
  20. const songTitleElem = document.querySelector<HTMLDivElement>(".content-info-wrapper > yt-formatted-string");
  21. if(!songTitleElem)
  22. return warn("Couldn't find song title element");
  23. currentSongTitle = songTitleElem.title;
  24. const spinnerIconUrl = await getResourceUrl("icon-spinner");
  25. const lyricsIconUrl = await getResourceUrl("icon-lyrics");
  26. const errorIconUrl = await getResourceUrl("icon-error");
  27. const onMutation = async (mutations: MutationRecord[]) => {
  28. for await(const mut of mutations) {
  29. const newTitle = (mut.target as HTMLElement).title;
  30. if(newTitle !== currentSongTitle && newTitle.length > 0) {
  31. const lyricsBtn = document.querySelector<HTMLAnchorElement>("#bytm-player-bar-lyrics-btn");
  32. if(!lyricsBtn)
  33. continue;
  34. lyricsBtn.style.cursor = "wait";
  35. lyricsBtn.style.pointerEvents = "none";
  36. const imgElem = lyricsBtn.querySelector<HTMLImageElement>("img")!;
  37. imgElem.src = spinnerIconUrl;
  38. imgElem.classList.add("bytm-spinner");
  39. currentSongTitle = newTitle;
  40. const url = await getCurrentLyricsUrl(); // can take a second or two
  41. imgElem.src = lyricsIconUrl;
  42. imgElem.classList.remove("bytm-spinner");
  43. if(!url) {
  44. let artist, song;
  45. if("mediaSession" in navigator && navigator.mediaSession.metadata) {
  46. artist = navigator.mediaSession.metadata.artist;
  47. song = navigator.mediaSession.metadata.title;
  48. }
  49. const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
  50. imgElem.src = errorIconUrl;
  51. lyricsBtn.ariaLabel = lyricsBtn.title = t("lyrics_not_found_click_open_search");
  52. lyricsBtn.style.cursor = "pointer";
  53. lyricsBtn.style.pointerEvents = "all";
  54. lyricsBtn.style.display = "inline-flex";
  55. lyricsBtn.style.visibility = "visible";
  56. lyricsBtn.href = `https://genius.com/search${query}`;
  57. continue;
  58. }
  59. lyricsBtn.href = url;
  60. lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
  61. lyricsBtn.style.cursor = "pointer";
  62. lyricsBtn.style.visibility = "visible";
  63. lyricsBtn.style.display = "inline-flex";
  64. lyricsBtn.style.pointerEvents = "initial";
  65. }
  66. }
  67. };
  68. // 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
  69. const obs = new MutationObserver(onMutation);
  70. obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] });
  71. const lyricsBtnElem = await createLyricsBtn(undefined);
  72. lyricsBtnElem.id = "bytm-player-bar-lyrics-btn";
  73. // run parallel so the element is inserted as soon as possible
  74. getCurrentLyricsUrl().then(url => {
  75. url && addGeniusUrlToLyricsBtn(lyricsBtnElem, url);
  76. });
  77. log("Inserted lyrics button into media controls bar");
  78. const thumbToggleElem = document.querySelector<HTMLElement>("#bytm-thumbnail-overlay-toggle");
  79. if(thumbToggleElem)
  80. thumbToggleElem.insertAdjacentElement("afterend", lyricsBtnElem);
  81. else
  82. likeContainer.insertAdjacentElement("afterend", lyricsBtnElem);
  83. }
  84. //#region lyrics utils
  85. /** Removes everything in parentheses from the passed song name */
  86. export function sanitizeSong(songName: string) {
  87. if(typeof songName !== "string")
  88. return songName;
  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. if(artists.match(/(f(ea)?t\.?|Remix|Edit|Flip|Cover|Night\s?Core|Bass\s?Boost|pro?d\.?)/i)) {
  105. const parensRegex = /\(.+\)/gmi;
  106. const squareParensRegex = /\[.+\]/gmi;
  107. artists = artists
  108. .replace(parensRegex, "")
  109. .replace(squareParensRegex, "");
  110. }
  111. return artists.trim();
  112. }
  113. /** Returns the lyrics URL from genius for the currently selected song */
  114. export async function getCurrentLyricsUrl() {
  115. try {
  116. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  117. const isVideo = currentMediaType() === "video";
  118. const songTitleElem = document.querySelector<HTMLElement>(".content-info-wrapper > yt-formatted-string");
  119. const songMetaElem = document.querySelector<HTMLElement>("span.subtitle > yt-formatted-string :first-child");
  120. if(!songTitleElem || !songMetaElem)
  121. return undefined;
  122. const songNameRaw = songTitleElem.title;
  123. let songName = songNameRaw;
  124. let artistName = songMetaElem.textContent;
  125. if(isVideo) {
  126. // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  127. if(songName.includes("-")) {
  128. const split = splitVideoTitle(songName);
  129. songName = split.song;
  130. artistName = split.artist;
  131. }
  132. }
  133. if(!artistName)
  134. return undefined;
  135. const url = await fetchLyricsUrlTop(sanitizeArtists(artistName), sanitizeSong(songName));
  136. if(url) {
  137. emitInterface("bytm:lyricsLoaded", {
  138. type: "current",
  139. artists: artistName,
  140. title: songName,
  141. url,
  142. });
  143. }
  144. return url;
  145. }
  146. catch(err) {
  147. error("Couldn't resolve lyrics URL:", err);
  148. return undefined;
  149. }
  150. }
  151. /** Fetches the top lyrics URL result from geniURL - **the passed parameters need to be sanitized first!** */
  152. export async function fetchLyricsUrlTop(artist: string, song: string): Promise<string | undefined> {
  153. try {
  154. return (await fetchLyricsUrls(artist, song))?.[0]?.url;
  155. }
  156. catch(err) {
  157. error("Couldn't get lyrics URL due to error:", err);
  158. return undefined;
  159. }
  160. }
  161. /**
  162. * Fetches the 5 best matching lyrics URLs from geniURL using a combo exact-ish and fuzzy search
  163. * **the passed parameters need to be sanitized first!**
  164. */
  165. export async function fetchLyricsUrls(artist: string, song: string): Promise<Omit<LyricsCacheEntry, "added" | "viewed">[] | undefined> {
  166. try {
  167. const cacheEntry = getLyricsCacheEntry(artist, song);
  168. if(cacheEntry) {
  169. info(`Found lyrics URL in cache: ${cacheEntry.url}`);
  170. return [cacheEntry];
  171. }
  172. const startTs = Date.now();
  173. const fetchUrl = constructUrl(`${getFeature("geniUrlBase")}/search`, {
  174. disableFuzzy: null,
  175. utm_source: scriptInfo.name,
  176. utm_content: `v${scriptInfo.version}${mode === "development" ? "-dev" : ""}`,
  177. artist,
  178. song,
  179. });
  180. log("Requesting lyrics from geniURL:", fetchUrl);
  181. const token = getFeature("geniUrlToken");
  182. const fetchRes = await fetchAdvanced(fetchUrl, {
  183. ...(token ? {
  184. headers: {
  185. Authorization: `Bearer ${token}`,
  186. },
  187. } : {}),
  188. });
  189. if(fetchRes.status === 429) {
  190. const waitSeconds = Number(fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe);
  191. alert(tp("lyrics_rate_limited", waitSeconds, waitSeconds));
  192. return undefined;
  193. }
  194. else if(fetchRes.status < 200 || fetchRes.status >= 300) {
  195. error(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(await fetchRes.json()).message ?? await fetchRes.text() ?? "(none)"}`);
  196. return undefined;
  197. }
  198. const result = await fetchRes.json();
  199. if(typeof result === "object" && result.error || !result || !result.all) {
  200. error("Couldn't fetch lyrics URL:", result.message);
  201. return undefined;
  202. }
  203. const allResults = result.all as {
  204. url: string;
  205. meta: {
  206. title: string;
  207. fullTitle: string;
  208. artists: string;
  209. primaryArtist: {
  210. name: string;
  211. };
  212. };
  213. }[];
  214. if(allResults.length === 0) {
  215. warn("No lyrics URL found for the provided song");
  216. return undefined;
  217. }
  218. const allResultsSan = allResults
  219. .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url)
  220. .map(({ meta, url }) => ({
  221. meta: {
  222. ...meta,
  223. title: sanitizeSong(String(meta.title ?? meta.fullTitle)),
  224. artists: sanitizeArtists(String(meta.artists)),
  225. },
  226. url,
  227. }));
  228. if(!getFeature("advancedLyricsFilter")) {
  229. const topRes = allResultsSan[0];
  230. topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url);
  231. return allResultsSan.map(r => ({
  232. artist: r.meta.primaryArtist.name,
  233. song: r.meta.title,
  234. url: r.url,
  235. }));
  236. }
  237. const exactish = (input: string) => input.toLowerCase()
  238. .replace(/[\s\-_&,.()[\]]+/gm, "");
  239. // exact-ish matches, best matching one first
  240. const exactishResults = [...allResultsSan].sort((a, b) => {
  241. const aTitleScore = exactish(a.meta.title).localeCompare(exactish(song));
  242. const bTitleScore = exactish(b.meta.title).localeCompare(exactish(song));
  243. const aArtistScore = exactish(a.meta.primaryArtist.name).localeCompare(exactish(artist));
  244. const bArtistScore = exactish(b.meta.primaryArtist.name).localeCompare(exactish(artist));
  245. return aTitleScore + aArtistScore - bTitleScore - bArtistScore;
  246. });
  247. // use fuse.js for fuzzy match
  248. // search song title and artist separately, then combine the scores
  249. const titleFuse = new Fuse([...allResultsSan], {
  250. keys: ["title"],
  251. includeScore: true,
  252. threshold: 0.4,
  253. });
  254. const artistFuse = new Fuse([...allResultsSan], {
  255. keys: ["primaryArtist.name"],
  256. includeScore: true,
  257. threshold: 0.4,
  258. });
  259. let fuzzyResults: typeof allResultsSan = allResultsSan.map(r => {
  260. const titleRes = titleFuse.search(r.meta.title);
  261. const artistRes = artistFuse.search(r.meta.primaryArtist.name);
  262. const titleScore = titleRes[0]?.score ?? 0;
  263. const artistScore = artistRes[0]?.score ?? 0;
  264. return {
  265. ...r,
  266. score: titleScore + artistScore,
  267. };
  268. });
  269. // I love TS
  270. fuzzyResults = (fuzzyResults as (typeof allResultsSan[0] & { score: number })[])
  271. .map(({ score, ...rest }) => rest as typeof allResultsSan[0]);
  272. const hasExactMatch = exactishResults.slice(0, 3).find(r => exactish(r.meta.title) === exactish(fuzzyResults[0].meta.title) && exactish(r.meta.primaryArtist.name) === exactish(fuzzyResults[0].meta.primaryArtist.name));
  273. const finalResults = [
  274. ...(
  275. hasExactMatch
  276. ? [fuzzyResults[0], ...allResultsSan.filter(r => r.url !== fuzzyResults[0].url)]
  277. : [...allResultsSan]
  278. ),
  279. ].slice(0, 5);
  280. // add top 3 results to the cache with a penalty to their time to live
  281. // so every entry is deleted faster if it's not considered as relevant
  282. finalResults.slice(0, 3).forEach(({ meta: { artists, title }, url }, i) => {
  283. const penaltyFraction = hasExactMatch
  284. // if there's an exact match, give it 0 penalty and penalize all other results with the full value
  285. ? i === 0 ? 0 : 1
  286. // if there's no exact match, penalize all results with a fraction of the full penalty since they're more likely to be unrelated
  287. : 0.6;
  288. addLyricsCacheEntryPenalized(sanitizeArtists(artists), sanitizeSong(title), url, penaltyFraction);
  289. });
  290. finalResults.length > 0 && log("Found", finalResults.length, "lyrics", autoPlural("URL", finalResults), "in", Date.now() - startTs, "ms:", finalResults);
  291. // returns search results sorted by relevance
  292. return finalResults.map(r => ({
  293. artist: r.meta.primaryArtist.name,
  294. song: r.meta.title,
  295. url: r.url,
  296. }));
  297. }
  298. catch(err) {
  299. error("Couldn't get lyrics URL due to error:", err);
  300. return undefined;
  301. }
  302. }
  303. /** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */
  304. export async function addGeniusUrlToLyricsBtn(btnElem: HTMLAnchorElement, geniusUrl: string) {
  305. btnElem.href = geniusUrl;
  306. btnElem.ariaLabel = btnElem.title = t("open_lyrics");
  307. btnElem.style.visibility = "visible";
  308. btnElem.style.display = "inline-flex";
  309. }
  310. /** Creates the base lyrics button element */
  311. export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) {
  312. const linkElem = document.createElement("a");
  313. linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
  314. linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
  315. if(geniusUrl)
  316. linkElem.href = geniusUrl;
  317. linkElem.role = "button";
  318. linkElem.target = "_blank";
  319. linkElem.rel = "noopener noreferrer";
  320. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  321. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  322. const imgElem = document.createElement("img");
  323. imgElem.classList.add("bytm-generic-btn-img");
  324. imgElem.src = await getResourceUrl("icon-lyrics");
  325. linkElem.appendChild(imgElem);
  326. return linkElem;
  327. }
  328. /** Splits a video title that contains a hyphen into an artist and song */
  329. export function splitVideoTitle(title: string) {
  330. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  331. return { artist, song: rest.join("-") };
  332. }