songData.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. /* eslint-disable no-control-regex */
  2. import { axios } from "./axios";
  3. import Fuse from "fuse.js";
  4. import { nanoid } from "nanoid";
  5. import { clamp, Stringifiable } from "svcorelib";
  6. import type { Album, ApiSearchResult, ApiSongResult, GetMetaArgs, GetMetaResult, GetTranslationsArgs, MetaSearchHit, SongMeta, SongTranslation } from "./types";
  7. const defaultFuzzyThreshold = 0.65;
  8. /**
  9. * Returns meta information about the top results of a search using the genius API
  10. * @param param0 URL parameters - needs either a `q` prop or the props `artist` and `song`
  11. */
  12. export async function getMeta({
  13. q,
  14. artist,
  15. song,
  16. threshold,
  17. preferLang,
  18. }: GetMetaArgs): Promise<GetMetaResult | null>
  19. {
  20. const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
  21. const query = q ? q : `${artist} ${song}`;
  22. const { data: { response }, status } = await axios.get<ApiSearchResult>(`https://api.genius.com/search?q=${encodeURIComponent(query)}`, {
  23. headers: { "Authorization": `Bearer ${accessToken}` },
  24. });
  25. if(threshold === undefined || isNaN(threshold))
  26. threshold = defaultFuzzyThreshold;
  27. threshold = clamp(threshold, 0.0, 1.0);
  28. if(status >= 200 && status < 300 && Array.isArray(response?.hits))
  29. {
  30. if(response.hits.length === 0)
  31. return null;
  32. let hits: MetaSearchHit[] = response.hits
  33. .filter(h => h.type === "song")
  34. .map(({ result }) => ({
  35. url: result.url,
  36. path: result.path,
  37. language: result.language ?? null,
  38. meta: {
  39. title: formatStr(result.title),
  40. fullTitle: formatStr(result.full_title),
  41. artists: formatStr(result.artist_names),
  42. primaryArtist: {
  43. name: result.primary_artist.name ? formatStr(result.primary_artist.name) : null,
  44. url: result.primary_artist.url ?? null,
  45. headerImage: result.primary_artist.header_image_url ?? null,
  46. image: result.primary_artist.image_url ?? null,
  47. },
  48. featuredArtists: Array.isArray(result.featured_artists) && result.featured_artists.length > 0
  49. ? result.featured_artists.map((a) => ({
  50. name: a.name ? formatStr(a.name) : null,
  51. url: a.url ?? null,
  52. headerImage: a.header_image_url ?? null,
  53. image: a.image_url ?? null,
  54. }))
  55. : [],
  56. releaseDate: result.release_date_components ?? null,
  57. },
  58. resources: {
  59. thumbnail: result.song_art_image_thumbnail_url ?? null,
  60. image: result.song_art_image_url ?? null,
  61. },
  62. lyricsState: result.lyrics_state ?? null,
  63. id: result.id ?? null,
  64. }));
  65. const scoreMap: Record<string, number> = {};
  66. hits = hits.map(h => {
  67. h.uuid = nanoid();
  68. return h;
  69. }) as (SongMeta & { uuid: string })[];
  70. const fuseOpts: Fuse.IFuseOptions<MetaSearchHit> = {
  71. includeScore: true,
  72. threshold,
  73. };
  74. const addScores = (searchRes: Fuse.FuseResult<SongMeta & { uuid?: string; }>[]) =>
  75. searchRes.forEach(({ item, score }) => {
  76. if(!item.uuid || !score)
  77. return;
  78. if(!scoreMap[item.uuid])
  79. scoreMap[item.uuid] = score;
  80. else
  81. scoreMap[item.uuid] += score;
  82. });
  83. if(song && artist) {
  84. const titleFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.title" ] });
  85. const artistFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.primaryArtist.name" ] });
  86. addScores(titleFuse.search(song));
  87. addScores(artistFuse.search(artist));
  88. }
  89. else {
  90. const queryFuse = new Fuse(hits, {
  91. ...fuseOpts,
  92. ignoreLocation: true,
  93. keys: [ "meta.title", "meta.primaryArtist.name" ],
  94. });
  95. let queryParts = [query];
  96. if(query.match(/\s-\s/))
  97. queryParts = query.split(/\s-\s/);
  98. for(const part of queryParts)
  99. addScores(queryFuse.search(part.trim()));
  100. }
  101. // TODO: reduce the amount of remapping cause it takes long
  102. const bestMatches = Object.entries(scoreMap)
  103. .sort(([, valA], [, valB]) => valA > valB ? 1 : -1)
  104. .map(e => e[0]);
  105. const oldHits = [...hits];
  106. hits = bestMatches
  107. .map(uuid => oldHits.find(h => h.uuid === uuid))
  108. .map(hit => {
  109. if(!hit) return undefined;
  110. delete hit.uuid;
  111. return hit;
  112. })
  113. .filter(h => h !== undefined) as MetaSearchHit[];
  114. if(hits.length === 0)
  115. return null;
  116. // splice out preferredLang results and move them to the beginning of the array, while keeping their original order:
  117. const preferredBestMatches: MetaSearchHit[] = [];
  118. if(preferLang) {
  119. hits.forEach((hit, i) => {
  120. if(hit.language === preferLang.toLowerCase())
  121. preferredBestMatches.push(hits.splice(i, 1)[0]!);
  122. });
  123. }
  124. const reorderedHits = preferredBestMatches.concat(hits);
  125. return {
  126. top: reorderedHits[0]!,
  127. all: reorderedHits.slice(0, 10),
  128. };
  129. }
  130. return null;
  131. }
  132. /**
  133. * Returns translations for a song with the specified ID
  134. * @param songId Song ID gotten from the /search endpoints
  135. * @param param1 URL parameters
  136. */
  137. export async function getTranslations(songId: number, { preferLang }: GetTranslationsArgs): Promise<SongTranslation[] | null> {
  138. try {
  139. const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
  140. const { data, status } = await axios.get<ApiSongResult>(`https://api.genius.com/songs/${songId}`, {
  141. headers: { "Authorization": `Bearer ${accessToken}` },
  142. });
  143. if(status >= 200 && status < 300 && Array.isArray(data?.response?.song?.translation_songs))
  144. {
  145. const { response: { song } } = data;
  146. const results = song.translation_songs
  147. .map(({ language, id, path, title, url }) => ({ language, title, url, path, id }));
  148. // splice out preferredLang results and move them to the beginning of the array, while keeping their original order:
  149. const preferredResults: SongTranslation[] = [];
  150. if(preferLang) {
  151. results.forEach((res, i) => {
  152. if(res.language === preferLang.toLowerCase())
  153. preferredResults.push(results.splice(i, 1)[0]!);
  154. });
  155. }
  156. return preferredResults.concat(results);
  157. }
  158. return null;
  159. }
  160. catch(e) {
  161. return null;
  162. }
  163. }
  164. export async function getAlbum(songId: number): Promise<Album | null> {
  165. try {
  166. const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
  167. const { data, status } = await axios.get<ApiSongResult>(`https://api.genius.com/songs/${songId}`, {
  168. headers: { "Authorization": `Bearer ${accessToken}` },
  169. });
  170. if(status >= 200 && status < 300 && data?.response?.song?.album?.id)
  171. {
  172. const { response: { song: { album } } } = data;
  173. return {
  174. name: album.name,
  175. fullTitle: album.full_title,
  176. url: album.url,
  177. coverArt: album.cover_art_url ?? null,
  178. id: album.id,
  179. artist: {
  180. name: album.artist.name ?? null,
  181. url: album.artist.url ?? null,
  182. image: album.artist.image_url ?? null,
  183. headerImage: album.artist.header_image_url ?? null,
  184. }
  185. };
  186. }
  187. return null;
  188. }
  189. catch(e) {
  190. return null;
  191. }
  192. }
  193. /**
  194. * Removes invisible characters and control characters from a string
  195. * @throws Throws TypeError if the input is not a string
  196. */
  197. function formatStr(str: Stringifiable): string
  198. {
  199. if(!str || !str.toString || typeof str !== "string")
  200. throw new TypeError("formatStr(): input is not a string");
  201. return str.toString()
  202. .replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "") // 0-width spaces & control characters
  203. .replace(/\u00A0/g, " "); // non-standard 1-width spaces
  204. }