123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243 |
- /* eslint-disable no-control-regex */
- import { axios } from "./axios";
- import Fuse from "fuse.js";
- import { nanoid } from "nanoid";
- import { clamp, Stringifiable } from "svcorelib";
- import type { Album, ApiSearchResult, ApiSongResult, GetMetaArgs, GetMetaResult, GetTranslationsArgs, MetaSearchHit, SongMeta, SongTranslation } from "./types";
- const defaultFuzzyThreshold = 0.65;
- /**
- * Returns meta information about the top results of a search using the genius API
- * @param param0 URL parameters - needs either a `q` prop or the props `artist` and `song`
- */
- export async function getMeta({
- q,
- artist,
- song,
- threshold,
- preferLang,
- }: GetMetaArgs): Promise<GetMetaResult | null>
- {
- const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
- const query = q ? q : `${artist} ${song}`;
- const { data: { response }, status } = await axios.get<ApiSearchResult>(`https://api.genius.com/search?q=${encodeURIComponent(query)}`, {
- headers: { "Authorization": `Bearer ${accessToken}` },
- });
- if(threshold === undefined || isNaN(threshold))
- threshold = defaultFuzzyThreshold;
- threshold = clamp(threshold, 0.0, 1.0);
- if(status >= 200 && status < 300 && Array.isArray(response?.hits))
- {
- if(response.hits.length === 0)
- return null;
- let hits: MetaSearchHit[] = response.hits
- .filter(h => h.type === "song")
- .map(({ result }) => ({
- url: result.url,
- path: result.path,
- language: result.language ?? null,
- meta: {
- title: formatStr(result.title),
- fullTitle: formatStr(result.full_title),
- artists: formatStr(result.artist_names),
- primaryArtist: {
- name: result.primary_artist.name ? formatStr(result.primary_artist.name) : null,
- url: result.primary_artist.url ?? null,
- headerImage: result.primary_artist.header_image_url ?? null,
- image: result.primary_artist.image_url ?? null,
- },
- featuredArtists: Array.isArray(result.featured_artists) && result.featured_artists.length > 0
- ? result.featured_artists.map((a) => ({
- name: a.name ? formatStr(a.name) : null,
- url: a.url ?? null,
- headerImage: a.header_image_url ?? null,
- image: a.image_url ?? null,
- }))
- : [],
- releaseDate: result.release_date_components ?? null,
- },
- resources: {
- thumbnail: result.song_art_image_thumbnail_url ?? null,
- image: result.song_art_image_url ?? null,
- },
- lyricsState: result.lyrics_state ?? null,
- id: result.id ?? null,
- }));
- const scoreMap: Record<string, number> = {};
- hits = hits.map(h => {
- h.uuid = nanoid();
- return h;
- }) as (SongMeta & { uuid: string })[];
- const fuseOpts: Fuse.IFuseOptions<MetaSearchHit> = {
- includeScore: true,
- threshold,
- };
- const addScores = (searchRes: Fuse.FuseResult<SongMeta & { uuid?: string; }>[]) =>
- searchRes.forEach(({ item, score }) => {
- if(!item.uuid || !score)
- return;
- if(!scoreMap[item.uuid])
- scoreMap[item.uuid] = score;
- else
- scoreMap[item.uuid] += score;
- });
- if(song && artist) {
- const titleFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.title" ] });
- const artistFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.primaryArtist.name" ] });
- addScores(titleFuse.search(song));
- addScores(artistFuse.search(artist));
- }
- else {
- const queryFuse = new Fuse(hits, {
- ...fuseOpts,
- ignoreLocation: true,
- keys: [ "meta.title", "meta.primaryArtist.name" ],
- });
- let queryParts = [query];
- if(query.match(/\s-\s/))
- queryParts = query.split(/\s-\s/);
- for(const part of queryParts)
- addScores(queryFuse.search(part.trim()));
- }
- // TODO: reduce the amount of remapping cause it takes long
- const bestMatches = Object.entries(scoreMap)
- .sort(([, valA], [, valB]) => valA > valB ? 1 : -1)
- .map(e => e[0]);
- const oldHits = [...hits];
- hits = bestMatches
- .map(uuid => oldHits.find(h => h.uuid === uuid))
- .map(hit => {
- if(!hit) return undefined;
- delete hit.uuid;
- return hit;
- })
- .filter(h => h !== undefined) as MetaSearchHit[];
- if(hits.length === 0)
- return null;
- // splice out preferredLang results and move them to the beginning of the array, while keeping their original order:
- const preferredBestMatches: MetaSearchHit[] = [];
- if(preferLang) {
- hits.forEach((hit, i) => {
- if(hit.language === preferLang.toLowerCase())
- preferredBestMatches.push(hits.splice(i, 1)[0]!);
- });
- }
- const reorderedHits = preferredBestMatches.concat(hits);
- return {
- top: reorderedHits[0]!,
- all: reorderedHits.slice(0, 10),
- };
- }
- return null;
- }
- /**
- * Returns translations for a song with the specified ID
- * @param songId Song ID gotten from the /search endpoints
- * @param param1 URL parameters
- */
- export async function getTranslations(songId: number, { preferLang }: GetTranslationsArgs): Promise<SongTranslation[] | null> {
- try {
- const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
- const { data, status } = await axios.get<ApiSongResult>(`https://api.genius.com/songs/${songId}`, {
- headers: { "Authorization": `Bearer ${accessToken}` },
- });
- if(status >= 200 && status < 300 && Array.isArray(data?.response?.song?.translation_songs))
- {
- const { response: { song } } = data;
- const results = song.translation_songs
- .map(({ language, id, path, title, url }) => ({ language, title, url, path, id }));
- // splice out preferredLang results and move them to the beginning of the array, while keeping their original order:
- const preferredResults: SongTranslation[] = [];
- if(preferLang) {
- results.forEach((res, i) => {
- if(res.language === preferLang.toLowerCase())
- preferredResults.push(results.splice(i, 1)[0]!);
- });
- }
- return preferredResults.concat(results);
- }
- return null;
- }
- catch(e) {
- return null;
- }
- }
- export async function getAlbum(songId: number): Promise<Album | null> {
- try {
- const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
- const { data, status } = await axios.get<ApiSongResult>(`https://api.genius.com/songs/${songId}`, {
- headers: { "Authorization": `Bearer ${accessToken}` },
- });
- if(status >= 200 && status < 300 && data?.response?.song?.album?.id)
- {
- const { response: { song: { album } } } = data;
- return {
- name: album.name,
- fullTitle: album.full_title,
- url: album.url,
- coverArt: album.cover_art_url ?? null,
- id: album.id,
- artist: {
- name: album.artist.name ?? null,
- url: album.artist.url ?? null,
- image: album.artist.image_url ?? null,
- headerImage: album.artist.header_image_url ?? null,
- }
- };
- }
- return null;
- }
- catch(e) {
- return null;
- }
- }
- /**
- * Removes invisible characters and control characters from a string
- * @throws Throws TypeError if the input is not a string
- */
- function formatStr(str: Stringifiable): string
- {
- if(!str || !str.toString || typeof str !== "string")
- throw new TypeError("formatStr(): input is not a string");
- return str.toString()
- .replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "") // 0-width spaces & control characters
- .replace(/\u00A0/g, " "); // non-standard 1-width spaces
- }
|