lyrics.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410
  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/index.js";
  4. import { emitInterface } from "../interface.js";
  5. import { mode, scriptInfo } from "../constants.js";
  6. import { getFeature } from "../config.js";
  7. import { addLyricsCacheEntryBest, addLyricsCacheEntryPenalized, getLyricsCacheEntry } from "./lyricsCache.js";
  8. import type { LyricsCacheEntry } from "../types.js";
  9. import { addSelectorListener } from "../observers.js";
  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 player bar */
  15. export async function addPlayerBarLyricsBtn() {
  16. addSelectorListener("playerBarMiddleButtons", "ytmusic-like-button-renderer#like-button-renderer", { listener: addActualLyricsBtn });
  17. }
  18. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  19. async function addActualLyricsBtn(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} v${scriptInfo.version}${mode === "development" ? "-pre" : ""}`,
  176. q: `${artist} ${song}`,
  177. });
  178. log("Requesting lyrics from geniURL:", fetchUrl);
  179. const token = getFeature("geniUrlToken");
  180. const fetchRes = await fetchAdvanced(fetchUrl, {
  181. ...(token ? {
  182. headers: {
  183. Authorization: `Bearer ${token}`,
  184. },
  185. } : {}),
  186. });
  187. if(fetchRes.status === 429) {
  188. const waitSeconds = Number(fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe);
  189. alert(tp("lyrics_rate_limited", waitSeconds, waitSeconds));
  190. return undefined;
  191. }
  192. else if(fetchRes.status < 200 || fetchRes.status >= 300) {
  193. error(`Couldn't fetch lyrics URLs from geniURL - status: ${fetchRes.status} - response: ${(await fetchRes.json()).message ?? await fetchRes.text() ?? "(none)"}`);
  194. return undefined;
  195. }
  196. const result = await fetchRes.json();
  197. if(typeof result === "object" && result.error || !result || !result.all) {
  198. error("Couldn't fetch lyrics URL:", result.message);
  199. return undefined;
  200. }
  201. const allResults = result.all as {
  202. url: string;
  203. meta: {
  204. title: string;
  205. fullTitle: string;
  206. artists: string;
  207. primaryArtist: {
  208. name: string;
  209. };
  210. };
  211. }[];
  212. if(allResults.length === 0) {
  213. warn("No lyrics URL found for the provided song");
  214. return undefined;
  215. }
  216. const allResultsSan = allResults
  217. .filter(({ meta, url }) => (meta.title || meta.fullTitle) && meta.artists && url)
  218. .map(({ meta, url }) => ({
  219. meta: {
  220. ...meta,
  221. title: sanitizeSong(String(meta.title ?? meta.fullTitle)),
  222. artists: sanitizeArtists(String(meta.artists)),
  223. },
  224. url,
  225. }));
  226. if(!getFeature("advancedLyricsFilter")) {
  227. const topRes = allResultsSan[0];
  228. topRes && addLyricsCacheEntryBest(topRes.meta.artists, topRes.meta.title, topRes.url);
  229. return allResultsSan.map(r => ({
  230. artist: r.meta.primaryArtist.name,
  231. song: r.meta.title,
  232. url: r.url,
  233. }));
  234. }
  235. const exactish = (input: string) => input.toLowerCase()
  236. .replace(/[\s\-_&,.()[\]]+/gm, "");
  237. // exact-ish matches, best matching one first
  238. const exactishResults = [...allResultsSan].sort((a, b) => {
  239. const aTitleScore = exactish(a.meta.title).localeCompare(exactish(song));
  240. const bTitleScore = exactish(b.meta.title).localeCompare(exactish(song));
  241. const aArtistScore = exactish(a.meta.primaryArtist.name).localeCompare(exactish(artist));
  242. const bArtistScore = exactish(b.meta.primaryArtist.name).localeCompare(exactish(artist));
  243. return aTitleScore + aArtistScore - bTitleScore - bArtistScore;
  244. });
  245. // use fuse.js for fuzzy match
  246. // search song title and artist separately, then combine the scores
  247. const titleFuse = new Fuse([...allResultsSan], {
  248. keys: ["title"],
  249. includeScore: true,
  250. threshold: 0.4,
  251. });
  252. const artistFuse = new Fuse([...allResultsSan], {
  253. keys: ["primaryArtist.name"],
  254. includeScore: true,
  255. threshold: 0.4,
  256. });
  257. let fuzzyResults: typeof allResultsSan = allResultsSan.map(r => {
  258. const titleRes = titleFuse.search(r.meta.title);
  259. const artistRes = artistFuse.search(r.meta.primaryArtist.name);
  260. const titleScore = titleRes[0]?.score ?? 0;
  261. const artistScore = artistRes[0]?.score ?? 0;
  262. return {
  263. ...r,
  264. score: titleScore + artistScore,
  265. };
  266. });
  267. // I love TS
  268. fuzzyResults = (fuzzyResults as (typeof allResultsSan[0] & { score: number })[])
  269. .map(({ score, ...rest }) => rest as typeof allResultsSan[0]);
  270. 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));
  271. const finalResults = [
  272. ...(
  273. hasExactMatch
  274. ? [fuzzyResults[0], ...allResultsSan.filter(r => r.url !== fuzzyResults[0].url)]
  275. : [...allResultsSan]
  276. ),
  277. ].slice(0, 5);
  278. // add top 3 results to the cache with a penalty to their time to live
  279. // so every entry is deleted faster if it's not considered as relevant
  280. finalResults.slice(0, 3).forEach(({ meta: { artists, title }, url }, i) => {
  281. const penaltyFraction = hasExactMatch
  282. // if there's an exact match, give it 0 penalty and penalize all other results with the full value
  283. ? i === 0 ? 0 : 1
  284. // if there's no exact match, penalize all results with a fraction of the full penalty since they're more likely to be unrelated
  285. : 0.6;
  286. addLyricsCacheEntryPenalized(sanitizeArtists(artists), sanitizeSong(title), url, penaltyFraction);
  287. });
  288. finalResults.length > 0 && log("Found", finalResults.length, "lyrics", autoPlural("URL", finalResults), "in", Date.now() - startTs, "ms:", finalResults);
  289. // returns search results sorted by relevance
  290. return finalResults.map(r => ({
  291. artist: r.meta.primaryArtist.name,
  292. song: r.meta.title,
  293. url: r.url,
  294. }));
  295. }
  296. catch(err) {
  297. error("Couldn't get lyrics URL due to error:", err);
  298. return undefined;
  299. }
  300. }
  301. /** Adds the genius URL to the passed lyrics button element if it was previously instantiated with an undefined URL */
  302. export async function addGeniusUrlToLyricsBtn(btnElem: HTMLAnchorElement, geniusUrl: string) {
  303. btnElem.href = geniusUrl;
  304. btnElem.ariaLabel = btnElem.title = t("open_lyrics");
  305. btnElem.style.visibility = "visible";
  306. btnElem.style.display = "inline-flex";
  307. }
  308. /** Creates the base lyrics button element */
  309. export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) {
  310. const linkElem = document.createElement("a");
  311. linkElem.classList.add("ytmusic-player-bar", "bytm-generic-btn");
  312. linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
  313. if(geniusUrl)
  314. linkElem.href = geniusUrl;
  315. linkElem.role = "button";
  316. linkElem.target = "_blank";
  317. linkElem.rel = "noopener noreferrer";
  318. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  319. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  320. const imgElem = document.createElement("img");
  321. imgElem.classList.add("bytm-generic-btn-img");
  322. imgElem.src = await getResourceUrl("icon-lyrics");
  323. linkElem.appendChild(imgElem);
  324. return linkElem;
  325. }
  326. /** Splits a video title that contains a hyphen into an artist and song */
  327. export function splitVideoTitle(title: string) {
  328. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  329. return { artist, song: rest.join("-") };
  330. }