lyrics.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349
  1. import { ConfigManager, compress, decompress, fetchAdvanced, insertAfter } from "@sv443-network/userutils";
  2. import { constructUrlString, error, getResourceUrl, info, log, onSelectorOld, warn, t, tp, compressionSupported } from "../utils";
  3. import { emitInterface } from "../interface";
  4. import { compressionFormat, scriptInfo } from "../constants";
  5. import type { LyricsCacheEntry } from "../types";
  6. /** Base URL of geniURL */
  7. export const geniUrlBase = "https://api.sv443.net/geniurl";
  8. /** GeniURL endpoint that gives song metadata when provided with a `?q` or `?artist` and `?song` parameter - [more info](https://api.sv443.net/geniurl) */
  9. const geniURLSearchTopUrl = `${geniUrlBase}/search/top`;
  10. /** Ratelimit budget timeframe in seconds - should reflect what's in geniURL's docs */
  11. const geniUrlRatelimitTimeframe = 30;
  12. //#MARKER new cache
  13. /** How many cache entries can exist at a time - this is used to cap memory usage */
  14. const maxLyricsCacheSize = 1000;
  15. /** Maximum time before a cache entry is force deleted */
  16. const cacheTTL = 1000 * 60 * 60 * 24 * 30; // 30 days
  17. export type LyricsCache = {
  18. cache: LyricsCacheEntry[];
  19. };
  20. let canCompress = true;
  21. const lyricsCache = new ConfigManager<LyricsCache>({
  22. id: "bytm-lyrics-cache",
  23. defaultConfig: {
  24. cache: [],
  25. },
  26. formatVersion: 1,
  27. encodeData: (data) => canCompress ? compress(data, compressionFormat, "string") : data,
  28. decodeData: (data) => canCompress ? decompress(data, compressionFormat, "string") : data,
  29. });
  30. export async function initLyricsCache() {
  31. canCompress = await compressionSupported();
  32. const data = await lyricsCache.loadData();
  33. log(`Loaded lyrics cache (${data.cache.length} entries):`, data);
  34. return data;
  35. }
  36. /**
  37. * Returns the cache entry for the passed artist and song, or undefined if it doesn't exist yet
  38. * {@linkcode artist} and {@linkcode song} need to be sanitized first!
  39. * @param refreshEntry If true, the timestamp of the entry will be set to the current time
  40. */
  41. export function getLyricsCacheEntry(artist: string, song: string, refreshEntry = true) {
  42. const { cache } = lyricsCache.getData();
  43. const entry = cache.find(e => e.artist === artist && e.song === song);
  44. if(entry && Date.now() - entry?.added > cacheTTL) {
  45. deleteLyricsCacheEntry(artist, song);
  46. return undefined;
  47. }
  48. // refresh timestamp of the entry by mutating cache
  49. if(entry && refreshEntry)
  50. updateLyricsCacheEntry(artist, song);
  51. return entry;
  52. }
  53. function updateLyricsCacheEntry(artist: string, song: string) {
  54. const { cache } = lyricsCache.getData();
  55. const idx = cache.findIndex(e => e.artist === artist && e.song === song);
  56. if(idx !== -1) {
  57. const newEntry = cache.splice(idx, 1)[0]!;
  58. newEntry.viewed = Date.now();
  59. lyricsCache.setData({ cache: [ newEntry, ...cache ] });
  60. }
  61. }
  62. function deleteLyricsCacheEntry(artist: string, song: string) {
  63. const { cache } = lyricsCache.getData();
  64. const idx = cache.findIndex(e => e.artist === artist && e.song === song);
  65. if(idx !== -1) {
  66. cache.splice(idx, 1);
  67. lyricsCache.setData({ cache });
  68. }
  69. }
  70. /** Returns the full lyrics cache array */
  71. export function getLyricsCache() {
  72. return lyricsCache.getData().cache;
  73. }
  74. /**
  75. * Adds the provided entry into the lyrics URL cache, synchronously to RAM and asynchronously to GM storage
  76. * {@linkcode artist} and {@linkcode song} need to be sanitized first!
  77. */
  78. export function addLyricsCacheEntry(artist: string, song: string, url: string) {
  79. const { cache } = lyricsCache.getData();
  80. cache.push({
  81. artist, song, url, viewed: Date.now(), added: Date.now(),
  82. } satisfies LyricsCacheEntry);
  83. cache.sort((a, b) => b.viewed - a.viewed);
  84. if(cache.length > maxLyricsCacheSize)
  85. cache.pop();
  86. return lyricsCache.setData({ cache });
  87. }
  88. //#MARKER media control bar
  89. let currentSongTitle = "";
  90. /** Adds a lyrics button to the media controls bar */
  91. export async function addMediaCtrlLyricsBtn() {
  92. onSelectorOld(".middle-controls-buttons ytmusic-like-button-renderer#like-button-renderer", { listener: addActualMediaCtrlLyricsBtn });
  93. }
  94. /** Actually adds the lyrics button after the like button renderer has been verified to exist */
  95. async function addActualMediaCtrlLyricsBtn(likeContainer: HTMLElement) {
  96. const songTitleElem = document.querySelector<HTMLDivElement>(".content-info-wrapper > yt-formatted-string");
  97. if(!songTitleElem)
  98. return warn("Couldn't find song title element");
  99. // run parallel without awaiting so the MutationObserver below can observe the title element in time
  100. (async () => {
  101. const gUrl = await getCurrentLyricsUrl();
  102. const linkElem = await createLyricsBtn(gUrl ?? undefined);
  103. linkElem.id = "betterytm-lyrics-button";
  104. log("Inserted lyrics button into media controls bar");
  105. insertAfter(likeContainer, linkElem);
  106. })();
  107. currentSongTitle = songTitleElem.title;
  108. const spinnerIconUrl = await getResourceUrl("img-spinner");
  109. const lyricsIconUrl = await getResourceUrl("img-lyrics");
  110. const errorIconUrl = await getResourceUrl("img-error");
  111. const onMutation = async (mutations: MutationRecord[]) => {
  112. for await(const mut of mutations) {
  113. const newTitle = (mut.target as HTMLElement).title;
  114. if(newTitle !== currentSongTitle && newTitle.length > 0) {
  115. const lyricsBtn = document.querySelector<HTMLAnchorElement>("#betterytm-lyrics-button");
  116. if(!lyricsBtn)
  117. continue;
  118. info(`Song title changed from '${currentSongTitle}' to '${newTitle}'`);
  119. lyricsBtn.style.cursor = "wait";
  120. lyricsBtn.style.pointerEvents = "none";
  121. const imgElem = lyricsBtn.querySelector<HTMLImageElement>("img")!;
  122. imgElem.src = spinnerIconUrl;
  123. imgElem.classList.add("bytm-spinner");
  124. currentSongTitle = newTitle;
  125. const url = await getCurrentLyricsUrl(); // can take a second or two
  126. imgElem.src = lyricsIconUrl;
  127. imgElem.classList.remove("bytm-spinner");
  128. if(!url) {
  129. let artist, song;
  130. if("mediaSession" in navigator && navigator.mediaSession.metadata) {
  131. artist = navigator.mediaSession.metadata.artist;
  132. song = navigator.mediaSession.metadata.title;
  133. }
  134. const query = artist && song ? "?q=" + encodeURIComponent(sanitizeArtists(artist) + " - " + sanitizeSong(song)) : "";
  135. imgElem.src = errorIconUrl;
  136. imgElem.ariaLabel = imgElem.title = t("lyrics_not_found_click_open_search");
  137. lyricsBtn.style.cursor = "pointer";
  138. lyricsBtn.style.pointerEvents = "all";
  139. lyricsBtn.style.display = "inline-flex";
  140. lyricsBtn.style.visibility = "visible";
  141. lyricsBtn.href = `https://genius.com/search${query}`;
  142. continue;
  143. }
  144. lyricsBtn.href = url;
  145. lyricsBtn.ariaLabel = lyricsBtn.title = t("open_current_lyrics");
  146. lyricsBtn.style.cursor = "pointer";
  147. lyricsBtn.style.visibility = "visible";
  148. lyricsBtn.style.display = "inline-flex";
  149. lyricsBtn.style.pointerEvents = "initial";
  150. }
  151. }
  152. };
  153. // 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
  154. const obs = new MutationObserver(onMutation);
  155. obs.observe(songTitleElem, { attributes: true, attributeFilter: [ "title" ] });
  156. }
  157. //#MARKER utils
  158. /** Removes everything in parentheses from the passed song name */
  159. export function sanitizeSong(songName: string) {
  160. const parensRegex = /\(.+\)/gmi;
  161. const squareParensRegex = /\[.+\]/gmi;
  162. // trim right after the song name:
  163. const sanitized = songName
  164. .replace(parensRegex, "")
  165. .replace(squareParensRegex, "");
  166. return sanitized.trim();
  167. }
  168. /** Removes the secondary artist (if it exists) from the passed artists string */
  169. export function sanitizeArtists(artists: string) {
  170. artists = artists.split(/\s*\u2022\s*/gmiu)[0]; // split at &bull; [•] character
  171. if(artists.match(/&/))
  172. artists = artists.split(/\s*&\s*/gm)[0];
  173. if(artists.match(/,/))
  174. artists = artists.split(/,\s*/gm)[0];
  175. return artists.trim();
  176. }
  177. /** Returns the lyrics URL from genius for the currently selected song */
  178. export async function getCurrentLyricsUrl() {
  179. try {
  180. // In videos the video title contains both artist and song title, in "regular" YTM songs, the video title only contains the song title
  181. const isVideo = typeof document.querySelector("ytmusic-player")?.hasAttribute("video-mode");
  182. const songTitleElem = document.querySelector<HTMLElement>(".content-info-wrapper > yt-formatted-string");
  183. const songMetaElem = document.querySelector<HTMLElement>("span.subtitle > yt-formatted-string :first-child");
  184. if(!songTitleElem || !songMetaElem)
  185. return undefined;
  186. const songNameRaw = songTitleElem.title;
  187. let songName = songNameRaw;
  188. let artistName = songMetaElem.textContent;
  189. if(isVideo) {
  190. // for some fucking reason some music videos have YTM-like song title and artist separation, some don't
  191. if(songName.includes("-")) {
  192. const split = splitVideoTitle(songName);
  193. songName = split.song;
  194. artistName = split.artist;
  195. }
  196. }
  197. if(!artistName)
  198. return undefined;
  199. const url = await fetchLyricsUrl(sanitizeArtists(artistName), sanitizeSong(songName));
  200. if(url) {
  201. emitInterface("bytm:lyricsLoaded", {
  202. type: "current",
  203. artists: artistName,
  204. title: songName,
  205. url,
  206. });
  207. }
  208. return url;
  209. }
  210. catch(err) {
  211. error("Couldn't resolve lyrics URL:", err);
  212. return undefined;
  213. }
  214. }
  215. /** Fetches the actual lyrics URL from geniURL - **the passed parameters need to be sanitized first!** */
  216. export async function fetchLyricsUrl(artist: string, song: string): Promise<string | undefined> {
  217. try {
  218. const cacheEntry = getLyricsCacheEntry(artist, song);
  219. if(cacheEntry) {
  220. info(`Found lyrics URL in cache: ${cacheEntry.url}`);
  221. return cacheEntry.url;
  222. }
  223. const startTs = Date.now();
  224. const fetchUrl = constructUrlString(geniURLSearchTopUrl, {
  225. disableFuzzy: null,
  226. utm_source: "BetterYTM",
  227. utm_content: `v${scriptInfo.version}`,
  228. artist,
  229. song,
  230. });
  231. log(`Requesting URL from geniURL at '${fetchUrl}'`);
  232. const fetchRes = await fetchAdvanced(fetchUrl);
  233. if(fetchRes.status === 429) {
  234. const waitSeconds = Number(fetchRes.headers.get("retry-after") ?? geniUrlRatelimitTimeframe);
  235. alert(tp("lyrics_rate_limited", waitSeconds, waitSeconds));
  236. return undefined;
  237. }
  238. else if(fetchRes.status < 200 || fetchRes.status >= 300) {
  239. error(`Couldn't fetch lyrics URL from geniURL - status: ${fetchRes.status} - response: ${(await fetchRes.json()).message ?? await fetchRes.text() ?? "(none)"}`);
  240. return undefined;
  241. }
  242. const result = await fetchRes.json();
  243. if(typeof result === "object" && result.error) {
  244. error("Couldn't fetch lyrics URL:", result.message);
  245. return undefined;
  246. }
  247. const url = result.url;
  248. info(`Found lyrics URL (after ${Date.now() - startTs}ms): ${url}`);
  249. addLyricsCacheEntry(artist, song, url);
  250. return url;
  251. }
  252. catch(err) {
  253. error("Couldn't get lyrics URL due to error:", err);
  254. return undefined;
  255. }
  256. }
  257. /** Creates the base lyrics button element */
  258. export async function createLyricsBtn(geniusUrl?: string, hideIfLoading = true) {
  259. const linkElem = document.createElement("a");
  260. linkElem.className = "ytmusic-player-bar bytm-generic-btn";
  261. linkElem.ariaLabel = linkElem.title = geniusUrl ? t("open_lyrics") : t("lyrics_loading");
  262. if(geniusUrl)
  263. linkElem.href = geniusUrl;
  264. linkElem.role = "button";
  265. linkElem.target = "_blank";
  266. linkElem.rel = "noopener noreferrer";
  267. linkElem.style.visibility = hideIfLoading && geniusUrl ? "initial" : "hidden";
  268. linkElem.style.display = hideIfLoading && geniusUrl ? "inline-flex" : "none";
  269. const imgElem = document.createElement("img");
  270. imgElem.className = "bytm-generic-btn-img";
  271. imgElem.src = await getResourceUrl("img-lyrics");
  272. linkElem.appendChild(imgElem);
  273. return linkElem;
  274. }
  275. /** Splits a video title that contains a hyphen into an artist and song */
  276. export function splitVideoTitle(title: string) {
  277. const [artist, ...rest] = title.split("-").map((v, i) => i < 2 ? v.trim() : v);
  278. return { artist, song: rest.join("-") };
  279. }