Selaa lähdekoodia

ref: change indent size

Sv443 1 vuosi sitten
vanhempi
commit
03dc9a763c
13 muutettua tiedostoa jossa 593 lisäystä ja 607 poistoa
  1. 4 4
      src/axios.ts
  2. 7 7
      src/constants.ts
  3. 5 6
      src/error.ts
  4. 15 16
      src/index.ts
  5. 20 22
      src/routes/album.ts
  6. 8 8
      src/routes/index.ts
  7. 58 64
      src/routes/search.ts
  8. 21 23
      src/routes/translations.ts
  9. 77 80
      src/server.ts
  10. 201 201
      src/songData.ts
  11. 100 100
      src/types.d.ts
  12. 46 46
      src/utils.ts
  13. 31 30
      test/latency-test.ts

+ 4 - 4
src/axios.ts

@@ -1,11 +1,11 @@
 import { default as _axios } from "axios";
 
 export const axios = _axios.create({
-    timeout: 1000 * 15,
+  timeout: 1000 * 15,
 });
 
 export function getAxiosAuthConfig(authToken?: string) {
-    return authToken ? {
-        headers: { "Authorization": `Bearer ${authToken}` },
-    } : {};
+  return authToken ? {
+    headers: { "Authorization": `Bearer ${authToken}` },
+  } : {};
 }

+ 7 - 7
src/constants.ts

@@ -1,8 +1,8 @@
 import type { IRateLimiterOptions } from "rate-limiter-flexible";
 
 export const rateLimitOptions: IRateLimiterOptions = {
-    points: 25,
-    duration: 30,
+  points: 25,
+  duration: 30,
 };
 
 /** Set of all supported [ISO 639-1 language codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) */
@@ -10,9 +10,9 @@ export const langCodes = new Set<string>(["aa","ab","ae","af","ak","am","an","ar
 
 /** Map of unicode variant characters and replacements used in normalizing fields before fuzzy filtering them */
 export const charReplacements = new Map<string, string>([
-    ["`´’︐︑ʻ", "'"],
-    ["“”", "\""],
-    [",", ","],
-    ["—─ ", "-"],
-    ["     ", " "],
+  ["`´’︐︑ʻ", "'"],
+  ["“”", "\""],
+  [",", ","],
+  ["—─ ", "-"],
+  ["     ", " "],
 ]);

+ 5 - 6
src/error.ts

@@ -6,11 +6,10 @@ import k from "kleur";
  * @param err Error instance that caused the error
  * @param fatal Exits with code 1 if set to true
  */
-export function error(msg: string, err?: Error, fatal = false)
-{
-    console.error("\n");
-    console.error(k.red(msg));
-    err && console.error(err);
+export function error(msg: string, err?: Error, fatal = false) {
+  console.error("\n");
+  console.error(k.red(msg));
+  err && console.error(err);
 
-    fatal && process.exit(1);
+  fatal && process.exit(1);
 }

+ 15 - 16
src/index.ts

@@ -5,24 +5,23 @@ import { error } from "./error";
 
 const { env } = process;
 
-async function init()
-{
-    try
-    {
-        const missingEnvVars = [
-            "HTTP_PORT",
-            "GENIUS_ACCESS_TOKEN",
-        ].reduce<string[]>((a, v) => ((typeof env[v] !== "string" || env[v]!.length < 1) ? a.concat(v) : a), []);
+async function init() {
+  try
+  {
+    const missingEnvVars = [
+      "HTTP_PORT",
+      "GENIUS_ACCESS_TOKEN",
+    ].reduce<string[]>((a, v) => ((typeof env[v] !== "string" || env[v]!.length < 1) ? a.concat(v) : a), []);
 
-        if(missingEnvVars.length > 0)
-            throw new TypeError(`Missing environment variable(s):\n- ${missingEnvVars.join("\n- ")}`);
+    if(missingEnvVars.length > 0)
+      throw new TypeError(`Missing environment variable(s):\n- ${missingEnvVars.join("\n- ")}`);
 
-        await server.init();
-    }
-    catch(err)
-    {
-        error("Error while initializing", err instanceof Error ? err : undefined, true);
-    }
+    await server.init();
+  }
+  catch(err)
+  {
+    error("Error while initializing", err instanceof Error ? err : undefined, true);
+  }
 }
 
 init();

+ 20 - 22
src/routes/album.ts

@@ -3,33 +3,31 @@ import { paramValid, respond } from "../utils";
 import { getAlbum } from "../songData";
 
 export function initAlbumRoutes(router: Router) {
-    router.get("/album", (req, res) => {
-        const format: string = req.query.format ? String(req.query.format) : "json";
+  router.get("/album", (req, res) => {
+    const format: string = req.query.format ? String(req.query.format) : "json";
 
-        return respond(res, "clientError", "No song ID provided", format);
-    });
+    return respond(res, "clientError", "No song ID provided", format);
+  });
 
-    router.get("/album/:songId", async (req, res) => {
-        try
-        {
-            const { songId } = req.params;
-            const { format: fmt } = req.query;
+  router.get("/album/:songId", async (req, res) => {
+    try {
+      const { songId } = req.params;
+      const { format: fmt } = req.query;
 
-            const format: string = fmt ? String(fmt) : "json";
+      const format: string = fmt ? String(fmt) : "json";
 
-            if(!paramValid(songId) || isNaN(Number(songId)))
-                return respond(res, "clientError", "Provided song ID is invalid", format);
+      if(!paramValid(songId) || isNaN(Number(songId)))
+        return respond(res, "clientError", "Provided song ID is invalid", format);
 
-            const album = await getAlbum(Number(songId));
+      const album = await getAlbum(Number(songId));
 
-            if(!album)
-                return respond(res, "clientError", "Couldn't find any associated album for this song", format, 0);
+      if(!album)
+        return respond(res, "clientError", "Couldn't find any associated album for this song", format, 0);
 
-            return respond(res, "success", { album }, format, 1);
-        }
-        catch(err)
-        {
-            return respond(res, "serverError", `Encountered an internal server error: ${err instanceof Error ? err.message : ""}`, "json");
-        }
-    });
+      return respond(res, "success", { album }, format, 1);
+    }
+    catch(err) {
+      return respond(res, "serverError", `Encountered an internal server error: ${err instanceof Error ? err.message : ""}`, "json");
+    }
+  });
 }

+ 8 - 8
src/routes/index.ts

@@ -6,19 +6,19 @@ import { initSearchRoutes } from "./search";
 import { initTranslationsRoutes } from "./translations";
 
 const routeFuncs: ((router: Router) => unknown)[] = [
-    initSearchRoutes,
-    initTranslationsRoutes,
-    initAlbumRoutes,
+  initSearchRoutes,
+  initTranslationsRoutes,
+  initAlbumRoutes,
 ];
 
 const router = Router();
 
 export function initRouter(app: Application) {
-    for(const initRoute of routeFuncs)
-        initRoute(router);
+  for(const initRoute of routeFuncs)
+    initRoute(router);
 
-    // redirect to GitHub page
-    router.get("/", (_req, res) => res.redirect(packageJson.homepage));
+  // redirect to GitHub page
+  router.get("/", (_req, res) => res.redirect(packageJson.homepage));
 
-    app.use("/", router);
+  app.use("/", router);
 }

+ 58 - 64
src/routes/search.ts

@@ -4,78 +4,72 @@ import { getMeta } from "../songData";
 import { langCodes } from "../constants";
 
 export function initSearchRoutes(router: Router) {
-    router.get("/search", async (req, res) => {
-        try
-        {
-            const { q, artist, song, format: fmt, threshold: thr, preferLang: prLang } = req.query;
+  router.get("/search", async (req, res) => {
+    try {
+      const { q, artist, song, format: fmt, threshold: thr, preferLang: prLang } = req.query;
 
-            const format: string = fmt ? String(fmt) : "json";
-            const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
-            const preferLang = paramValid(prLang) && langCodes.has(prLang.toLowerCase()) ? prLang.toLowerCase() : undefined;
+      const format: string = fmt ? String(fmt) : "json";
+      const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
+      const preferLang = paramValid(prLang) && langCodes.has(prLang.toLowerCase()) ? prLang.toLowerCase() : undefined;
 
-            if(paramValid(q) || (paramValid(artist) && paramValid(song)))
-            {
-                const meta = await getMeta({
-                    ...(q ? {
-                        q: String(q),
-                    } : {
-                        artist: String(artist),
-                        song: String(song),
-                    }),
-                    threshold,
-                    preferLang,
-                });
+      if(paramValid(q) || (paramValid(artist) && paramValid(song))) {
+        const meta = await getMeta({
+          ...(q ? {
+            q: String(q),
+          } : {
+            artist: String(artist),
+            song: String(song),
+          }),
+          threshold,
+          preferLang,
+        });
 
-                if(!meta || meta.all.length < 1)
-                    return respond(res, "clientError", "Found no results matching your search query", format, 0);
+        if(!meta || meta.all.length < 1)
+          return respond(res, "clientError", "Found no results matching your search query", format, 0);
 
-                // js2xmlparser needs special treatment when using arrays to produce a decent XML structure
-                const response = format !== "xml" ? meta : { ...meta, all: { "result": meta.all } };
+        // js2xmlparser needs special treatment when using arrays to produce a decent XML structure
+        const response = format !== "xml" ? meta : { ...meta, all: { "result": meta.all } };
 
-                return respond(res, "success", response, format, meta.all.length);
-            }
-            else
-                return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format ? String(req.query.format) : undefined);
-        }
-        catch(err)
-        {
-            return respond(res, "serverError", `Encountered an internal server error: ${err instanceof Error ? err.message : ""}`, "json");
-        }
-    });
+        return respond(res, "success", response, format, meta.all.length);
+      }
+      else
+        return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format ? String(req.query.format) : undefined);
+    }
+    catch(err) {
+      return respond(res, "serverError", `Encountered an internal server error: ${err instanceof Error ? err.message : ""}`, "json");
+    }
+  });
 
-    router.get("/search/top", async (req, res) => {
-        try
-        {
-            const { q, artist, song, format: fmt, threshold: thr, preferLang: prLang } = req.query;
+  router.get("/search/top", async (req, res) => {
+    try {
+      const { q, artist, song, format: fmt, threshold: thr, preferLang: prLang } = req.query;
 
-            const format: string = fmt ? String(fmt) : "json";
-            const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
-            const preferLang = paramValid(prLang) && langCodes.has(prLang.toLowerCase()) ? prLang.toLowerCase() : undefined;
+      const format: string = fmt ? String(fmt) : "json";
+      const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
+      const preferLang = paramValid(prLang) && langCodes.has(prLang.toLowerCase()) ? prLang.toLowerCase() : undefined;
 
-            if(paramValid(q) || (paramValid(artist) && paramValid(song)))
-            {
-                const meta = await getMeta({
-                    ...(q ? {
-                        q: String(q),
-                    } : {
-                        artist: String(artist),
-                        song: String(song),
-                    }),
-                    threshold,
-                    preferLang,
-                });
+      if(paramValid(q) || (paramValid(artist) && paramValid(song))) {
+        const meta = await getMeta({
+          ...(q ? {
+            q: String(q),
+          } : {
+            artist: String(artist),
+            song: String(song),
+          }),
+          threshold,
+          preferLang,
+        });
 
-                if(!meta || !meta.top)
-                    return respond(res, "clientError", "Found no results matching your search query", format, 0);
+        if(!meta || !meta.top)
+          return respond(res, "clientError", "Found no results matching your search query", format, 0);
 
-                return respond(res, "success", meta.top, format, 1);
-            }
-            else
-                return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format ? String(req.query.format) : undefined);
-        }
-        catch(err)
-        {
-            return respond(res, "serverError", `Encountered an internal server error${err instanceof Error ? err.message : ""}`, "json");
-        }
-    });
+        return respond(res, "success", meta.top, format, 1);
+      }
+      else
+        return respond(res, "clientError", "No search params (?q or ?song and ?artist) provided or they are invalid", req?.query?.format ? String(req.query.format) : undefined);
+    }
+    catch(err) {
+      return respond(res, "serverError", `Encountered an internal server error${err instanceof Error ? err.message : ""}`, "json");
+    }
+  });
 }

+ 21 - 23
src/routes/translations.ts

@@ -4,34 +4,32 @@ import { getTranslations } from "../songData";
 import { langCodes } from "../constants";
 
 export function initTranslationsRoutes(router: Router) {
-    router.get("/translations", (req, res) => {
-        const format: string = req.query.format ? String(req.query.format) : "json";
+  router.get("/translations", (req, res) => {
+    const format: string = req.query.format ? String(req.query.format) : "json";
 
-        return respond(res, "clientError", "No song ID provided", format);
-    });
+    return respond(res, "clientError", "No song ID provided", format);
+  });
 
-    router.get("/translations/:songId", async (req, res) => {
-        try
-        {
-            const { songId } = req.params;
-            const { format: fmt, preferLang: prLang } = req.query;
+  router.get("/translations/:songId", async (req, res) => {
+    try {
+      const { songId } = req.params;
+      const { format: fmt, preferLang: prLang } = req.query;
 
-            const format: string = fmt ? String(fmt) : "json";
-            const preferLang = paramValid(prLang) && langCodes.has(prLang.toLowerCase()) ? prLang.toLowerCase() : undefined;
+      const format: string = fmt ? String(fmt) : "json";
+      const preferLang = paramValid(prLang) && langCodes.has(prLang.toLowerCase()) ? prLang.toLowerCase() : undefined;
 
-            if(!paramValid(songId) || isNaN(Number(songId)))
-                return respond(res, "clientError", "Provided song ID is invalid", format);
+      if(!paramValid(songId) || isNaN(Number(songId)))
+        return respond(res, "clientError", "Provided song ID is invalid", format);
 
-            const translations = await getTranslations(Number(songId), { preferLang });
+      const translations = await getTranslations(Number(songId), { preferLang });
 
-            if(!translations || translations.length === 0)
-                return respond(res, "clientError", "Couldn't find translations for this song", format, 0);
+      if(!translations || translations.length === 0)
+        return respond(res, "clientError", "Couldn't find translations for this song", format, 0);
 
-            return respond(res, "success", { translations }, format, translations.length);
-        }
-        catch(err)
-        {
-            return respond(res, "serverError", `Encountered an internal server error: ${err instanceof Error ? err.message : ""}`, "json");
-        }
-    });
+      return respond(res, "success", { translations }, format, translations.length);
+    }
+    catch(err) {
+      return respond(res, "serverError", `Encountered an internal server error: ${err instanceof Error ? err.message : ""}`, "json");
+    }
+  });
 }

+ 77 - 80
src/server.ts

@@ -16,19 +16,19 @@ const { env } = process;
 const app = express();
 
 app.use(cors({
-    methods: "GET,HEAD,OPTIONS",
-    origin: "*",
+  methods: "GET,HEAD,OPTIONS",
+  origin: "*",
 }));
 app.use(helmet({ 
-    dnsPrefetchControl: true,
+  dnsPrefetchControl: true,
 }));
 app.use(compression({
-    threshold: 256
+  threshold: 256
 }));
 app.use(express.json());
 
 if(env.NODE_ENV?.toLowerCase() === "production")
-    app.enable("trust proxy");
+  app.enable("trust proxy");
 
 app.disable("x-powered-by");
 
@@ -36,88 +36,85 @@ const rateLimiter = new RateLimiterMemory(rateLimitOptions);
 
 const authTokens = getAuthTokens();
 
-export async function init()
-{
-    const port = parseInt(String(env.HTTP_PORT ?? "").trim());
-    const hostRaw = String(env.HTTP_HOST ?? "").trim();
-    const host = hostRaw.length < 1 ? "0.0.0.0" : hostRaw;
-
-    if(await portUsed(port))
-        return error(`TCP port ${port} is already used or invalid`, undefined, true);
-
-    // on error
-    app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
-        if(typeof err === "string" || err instanceof Error)
-            return respond(res, "serverError", `General error in HTTP server: ${err.toString()}`, req?.query?.format ? String(req.query.format) : undefined);
-        else
-            return next();
-    });
-
-    // preflight requests
-    app.options("*", cors());
-
-    // rate limiting
-    app.use(async (req, res, next) => {
-        const fmt = req?.query?.format ? String(req.query.format) : undefined;
-        const { authorization } = req.headers;
-        const authHeader = authorization?.startsWith("Bearer ") ? authorization.substring(7) : authorization;
-
-        res.setHeader("API-Info", `geniURL v${packageJson.version} (${packageJson.homepage})`);
-
-        if(authHeader && authTokens.has(authHeader))
-            return next();
-
-        const setRateLimitHeaders = (rateLimiterRes: RateLimiterRes) => {
-            if(rateLimiterRes.remainingPoints === 0)
-                res.setHeader("Retry-After", Math.ceil(rateLimiterRes.msBeforeNext / 1000));
-            res.setHeader("X-RateLimit-Limit", rateLimiter.points);
-            res.setHeader("X-RateLimit-Remaining", rateLimiterRes.remainingPoints);
-            res.setHeader("X-RateLimit-Reset", new Date(Date.now() + rateLimiterRes.msBeforeNext).toISOString());
-        };
-
-        rateLimiter.consume(req.ip)
-            .then((rateLimiterRes: RateLimiterRes) => {
-                setRateLimitHeaders(rateLimiterRes);
-                return next();
-            })
-            .catch((err) => {
-                if(err instanceof RateLimiterRes) {
-                    setRateLimitHeaders(err);
-                    return respond(res, 429, { error: true, matches: null, message: "You are being rate limited. Refer to the Retry-After header for when to try again." }, fmt);
-                }
-                else return respond(res, 500, { message: "Encountered an internal error. Please try again a little later." }, fmt);
-            });
-    });
-
-    const listener = app.listen(port, host, () => {
-        registerRoutes();
-
-        console.log(k.green(`Listening on ${host}:${port}`));
-    });
-
-    listener.on("error", (err) => error("General server error", err, true));
+export async function init() {
+  const port = parseInt(String(env.HTTP_PORT ?? "").trim());
+  const hostRaw = String(env.HTTP_HOST ?? "").trim();
+  const host = hostRaw.length < 1 ? "0.0.0.0" : hostRaw;
+
+  if(await portUsed(port))
+    return error(`TCP port ${port} is already used or invalid`, undefined, true);
+
+  // on error
+  app.use((err: unknown, req: Request, res: Response, next: NextFunction) => {
+    if(typeof err === "string" || err instanceof Error)
+      return respond(res, "serverError", `General error in HTTP server: ${err.toString()}`, req?.query?.format ? String(req.query.format) : undefined);
+    else
+      return next();
+  });
+
+  // preflight requests
+  app.options("*", cors());
+
+  // rate limiting
+  app.use(async (req, res, next) => {
+    const fmt = req?.query?.format ? String(req.query.format) : undefined;
+    const { authorization } = req.headers;
+    const authHeader = authorization?.startsWith("Bearer ") ? authorization.substring(7) : authorization;
+
+    res.setHeader("API-Info", `geniURL v${packageJson.version} (${packageJson.homepage})`);
+
+    if(authHeader && authTokens.has(authHeader))
+      return next();
+
+    const setRateLimitHeaders = (rateLimiterRes: RateLimiterRes) => {
+      if(rateLimiterRes.remainingPoints === 0)
+        res.setHeader("Retry-After", Math.ceil(rateLimiterRes.msBeforeNext / 1000));
+      res.setHeader("X-RateLimit-Limit", rateLimiter.points);
+      res.setHeader("X-RateLimit-Remaining", rateLimiterRes.remainingPoints);
+      res.setHeader("X-RateLimit-Reset", new Date(Date.now() + rateLimiterRes.msBeforeNext).toISOString());
+    };
+
+    rateLimiter.consume(req.ip)
+      .then((rateLimiterRes: RateLimiterRes) => {
+        setRateLimitHeaders(rateLimiterRes);
+        return next();
+      })
+      .catch((err) => {
+        if(err instanceof RateLimiterRes) {
+          setRateLimitHeaders(err);
+          return respond(res, 429, { error: true, matches: null, message: "You are being rate limited. Refer to the Retry-After header for when to try again." }, fmt);
+        }
+        else return respond(res, 500, { message: "Encountered an internal error. Please try again a little later." }, fmt);
+      });
+  });
+
+  const listener = app.listen(port, host, () => {
+    registerRoutes();
+
+    console.log(k.green(`Listening on ${host}:${port}`));
+  });
+
+  listener.on("error", (err) => error("General server error", err, true));
 }
 
 function registerRoutes()
 {
-    try
-    {
-        initRouter(app);
-    }
-    catch(err)
-    {
-        error("Error while initializing router", err instanceof Error ? err : undefined, true);
-    }
+  try {
+    initRouter(app);
+  }
+  catch(err) {
+    error("Error while initializing router", err instanceof Error ? err : undefined, true);
+  }
 }
 
 function getAuthTokens() {
-    const envVal = process.env["AUTH_TOKENS"];
-    let tokens: string[] = [];
+  const envVal = process.env["AUTH_TOKENS"];
+  let tokens: string[] = [];
 
-    if(!envVal || envVal.length === 0)
-        tokens = [];
-    else
-        tokens = envVal.split(/,/g);
+  if(!envVal || envVal.length === 0)
+    tokens = [];
+  else
+    tokens = envVal.split(/,/g);
 
-    return new Set<string>(tokens);
+  return new Set<string>(tokens);
 }

+ 201 - 201
src/songData.ts

@@ -15,153 +15,153 @@ const defaultFuzzyThreshold = 0.65;
  * @param param0 URL parameters - needs either a `q` prop or the props `artist` and `song`
  */
 export async function getMeta({
-    q,
-    artist,
-    song,
-    threshold,
-    preferLang,
+  q,
+  artist,
+  song,
+  threshold,
+  preferLang,
 }: GetMetaArgs): Promise<GetMetaResult | null>
 {
-    const query = q ? q : `${artist} ${song}`;
-
-    const {
-        data: { response },
-        status,
-    } = await axios.get<ApiSearchResult>(
-        `https://api.genius.com/search?q=${encodeURIComponent(query)}`,
-        getAxiosAuthConfig(process.env.GENIUS_ACCESS_TOKEN)
-    );
-
-    if(threshold === undefined || isNaN(threshold))
-        threshold = defaultFuzzyThreshold;
-    else
-        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: normalize(result.title),
-                    fullTitle: normalize(result.full_title),
-                    artists: normalize(result.artist_names),
-                    primaryArtist: {
-                        name: 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 ?? 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,
-        };
-
-        // TODO:FIXME: this entire thing is unreliable af
-        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/);
-
-            queryParts = queryParts.slice(0, 5);
-            for(const part of queryParts)
-                addScores(queryFuse.search(part.trim()));
-        }
+  const query = q ? q : `${artist} ${song}`;
+
+  const {
+    data: { response },
+    status,
+  } = await axios.get<ApiSearchResult>(
+    `https://api.genius.com/search?q=${encodeURIComponent(query)}`,
+    getAxiosAuthConfig(process.env.GENIUS_ACCESS_TOKEN)
+  );
+
+  if(threshold === undefined || isNaN(threshold))
+    threshold = defaultFuzzyThreshold;
+  else
+    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: normalize(result.title),
+          fullTitle: normalize(result.full_title),
+          artists: normalize(result.artist_names),
+          primaryArtist: {
+            name: 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 ?? 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,
+    };
+
+    // TODO:FIXME: this entire thing is unreliable af
+    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/);
+
+      queryParts = queryParts.slice(0, 5);
+      for(const part of queryParts)
+        addScores(queryFuse.search(part.trim()));
+    }
 
-        // TODO: reduce the amount of remapping cause it takes long
+    // 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 bestMatches = Object.entries(scoreMap)
+      .sort(([, valA], [, valB]) => valA > valB ? 1 : -1)
+      .map(e => e[0]);
 
-        const oldHits = [...hits];
+    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[];
+    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;
+    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[] = [];
+    // 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]!);
-            });
-        }
+    if(preferLang) {
+      hits.forEach((hit, i) => {
+        if(hit.language === preferLang.toLowerCase())
+          preferredBestMatches.push(hits.splice(i, 1)[0]!);
+      });
+    }
 
-        const reorderedHits = preferredBestMatches.concat(hits);
+    const reorderedHits = preferredBestMatches.concat(hits);
 
-        return {
-            top: reorderedHits[0]!,
-            all: reorderedHits.slice(0, 10),
-        };
-    }
+    return {
+      top: reorderedHits[0]!,
+      all: reorderedHits.slice(0, 10),
+    };
+  }
 
-    return null;
+  return null;
 }
 
 /**
@@ -170,86 +170,86 @@ export async function getMeta({
  * @param param1 URL parameters
  */
 export async function getTranslations(songId: number, { preferLang }: GetTranslationsArgs): Promise<SongTranslation[] | null> {
-    try {
-        const { data, status } = await axios.get<ApiSongResult>(
-            `https://api.genius.com/songs/${songId}`,
-            getAxiosAuthConfig(process.env.GENIUS_ACCESS_TOKEN)
-        );
-
-        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;
+  try {
+    const { data, status } = await axios.get<ApiSongResult>(
+      `https://api.genius.com/songs/${songId}`,
+      getAxiosAuthConfig(process.env.GENIUS_ACCESS_TOKEN)
+    );
+
+    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 { data, status } = await axios.get<ApiSongResult>(
-            `https://api.genius.com/songs/${songId}`,
-            getAxiosAuthConfig(process.env.GENIUS_ACCESS_TOKEN)
-        );
-
-        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,
-                }
-            };
+  try {
+    const { data, status } = await axios.get<ApiSongResult>(
+      `https://api.genius.com/songs/${songId}`,
+      getAxiosAuthConfig(process.env.GENIUS_ACCESS_TOKEN)
+    );
+
+    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;
+      };
     }
+    return null;
+  }
+  catch(e) {
+    return null;
+  }
 }
 
 const allReplaceCharsRegex = new RegExp(`[${
-    [...charReplacements.entries()].reduce((a, [chars]) => a + chars, "")
+  [...charReplacements.entries()].reduce((a, [chars]) => a + chars, "")
 }]`);
 
 const charReplacementRegexes = [...charReplacements.entries()]
-    .map(([chars, repl]) => ([new RegExp(`[${chars}]`, "g"), repl])) as [RegExp, string][];
+  .map(([chars, repl]) => ([new RegExp(`[${chars}]`, "g"), repl])) as [RegExp, string][];
 
 /** Removes invisible characters and control characters from a string and replaces weird unicode variants with the regular ASCII characters */
 function normalize(str: string): string
 {
-    if(str.match(allReplaceCharsRegex)) {
-        charReplacementRegexes.forEach(([regex, val]) => {
-            str = str.replace(regex, val);
-        });
-    }
-
-    return str
-        .replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "") // 0-width spaces & control characters
-        .replace(/\u00A0/g, " "); // non-standard 1-width spaces
+  if(str.match(allReplaceCharsRegex)) {
+    charReplacementRegexes.forEach(([regex, val]) => {
+      str = str.replace(regex, val);
+    });
+  }
+
+  return str
+    .replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "") // 0-width spaces & control characters
+    .replace(/\u00A0/g, " "); // non-standard 1-width spaces
 }

+ 100 - 100
src/types.d.ts

@@ -3,77 +3,77 @@
 export type ServerResponse<T> = SuccessResponse<T> | ErrorResponse;
 
 export type SuccessResponse<T> = {
-    error: false;
-    matches: number;
+  error: false;
+  matches: number;
 } & T;
 
 export type ErrorResponse = {
-    error: true;
-    matches: 0 | null;
-    message: string;
+  error: true;
+  matches: 0 | null;
+  message: string;
 }
 
 //#SECTION meta
 
 interface Artist {
-    name: string | null;
-    url: string | null;
-    image: string | null;
-    headerImage: string | null;
+  name: string | null;
+  url: string | null;
+  image: string | null;
+  headerImage: string | null;
 }
 
 /** geniURL song meta object */
 export interface SongMeta {
-    url: string;
-    path: string;
-    language: string | null;
-    meta: {
-        title: string;
-        fullTitle: string;
-        artists: string;
-        releaseDate: {
-            year: number | null;
-            month: number | null;
-            day: number | null;
-        };
-        primaryArtist: Artist | null;
-        featuredArtists: Artist[];
-    };
-    resources: {
-        thumbnail: string | null;
-        image: string | null;
+  url: string;
+  path: string;
+  language: string | null;
+  meta: {
+    title: string;
+    fullTitle: string;
+    artists: string;
+    releaseDate: {
+      year: number | null;
+      month: number | null;
+      day: number | null;
     };
-    lyricsState: string;
-    id: number;
+    primaryArtist: Artist | null;
+    featuredArtists: Artist[];
+  };
+  resources: {
+    thumbnail: string | null;
+    image: string | null;
+  };
+  lyricsState: string;
+  id: number;
 }
 
 export type MetaSearchHit = SongMeta & { uuid?: string; };
 
 export interface GetMetaArgs {
-    q?: string;
-    artist?: string;
-    song?: string;
-    threshold?: number;
-    preferLang?: string;
+  q?: string;
+  artist?: string;
+  song?: string;
+  threshold?: number;
+  preferLang?: string;
 }
 
 export interface GetMetaResult {
-    top: SongMeta;
-    all: SongMeta[];
+  top: SongMeta;
+  all: SongMeta[];
 }
 
 //#SECTION translations
 
 export interface SongTranslation {
-    language: string;
-    id: number;
-    path: string;
-    title: string;
-    url: string;
+  language: string;
+  id: number;
+  path: string;
+  title: string;
+  url: string;
 }
 
 export interface GetTranslationsArgs {
-    preferLang?: string;
+  preferLang?: string;
 }
 
 //#SECTION server
@@ -86,90 +86,90 @@ export type ResponseFormat = "json" | "xml";
 
 /** The entire object returned by the search endpoint of the genius API */
 export type ApiSearchResult = {
-    response: {
-        hits: SearchHit[];
-    };
+  response: {
+    hits: SearchHit[];
+  };
 };
 
 /** The entire object returned by the songs endpoint of the genius API */
 export type ApiSongResult = {
-    response: {
-        song: SongObj;
-    }
+  response: {
+    song: SongObj;
+  }
 }
 
 /** One result returned by the genius API search */
 export type SearchHit = {
-    type: "song";
-    result: SongBaseObj & {
-        release_date_components: {
-            year: number;
-            month: number;
-            day: number;
-        };
-        featured_artists: ArtistObj[];
+  type: "song";
+  result: SongBaseObj & {
+    release_date_components: {
+      year: number;
+      month: number;
+      day: number;
     };
+    featured_artists: ArtistObj[];
+  };
 };
 
 /** Result returned by the songs endpoint of the genius API */
 export type SongObj = SongBaseObj & {
-    album: {
-        api_path: string;
-        cover_art_url: string;
-        full_title: string;
-        id: number;
-        name: string;
-        url: string;
-        artist: ArtistObj;
-    },
-    translation_songs: {
-        api_path: string;
-        id: number;
-        language: string;
-        lyrics_state: string;
-        path: string;
-        title: string;
-        url: string;
-    }[];
-};
-
-type SongBaseObj = {
+  album: {
     api_path: string;
-    artist_names: string;
-    primary_artist: ArtistObj;
+    cover_art_url: string;
     full_title: string;
-    header_image_thumbnail_url: string;
-    header_image_url: string;
+    id: number;
+    name: string;
+    url: string;
+    artist: ArtistObj;
+  },
+  translation_songs: {
+    api_path: string;
     id: number;
     language: string;
-    lyrics_owner_id: number;
-    lyrics_state: "complete";
+    lyrics_state: string;
     path: string;
-    pyongs_count: number;
-    relationships_index_url: string;
-    song_art_image_thumbnail_url: string;
-    song_art_image_url: string;
     title: string;
-    title_with_featured: string;
     url: string;
+  }[];
+};
+
+type SongBaseObj = {
+  api_path: string;
+  artist_names: string;
+  primary_artist: ArtistObj;
+  full_title: string;
+  header_image_thumbnail_url: string;
+  header_image_url: string;
+  id: number;
+  language: string;
+  lyrics_owner_id: number;
+  lyrics_state: "complete";
+  path: string;
+  pyongs_count: number;
+  relationships_index_url: string;
+  song_art_image_thumbnail_url: string;
+  song_art_image_url: string;
+  title: string;
+  title_with_featured: string;
+  url: string;
 };
 
 type ArtistObj = {
-    api_path: string;
-    header_image_url: string;
-    id: number;
-    image_url: string;
-    name: string;
-    url: string;
+  api_path: string;
+  header_image_url: string;
+  id: number;
+  image_url: string;
+  name: string;
+  url: string;
 }
 
 export interface Album {
-    name: string;
-    fullTitle: string;
-    url: string;
-    coverArt: string | null;
-    id: number;
-    artist: Artist;
+  name: string;
+  fullTitle: string;
+  url: string;
+  coverArt: string | null;
+  id: number;
+  artist: Artist;
 }
 
 //#SECTION internal

+ 46 - 46
src/utils.ts

@@ -5,7 +5,7 @@ import { ResponseType } from "./types";
 
 /** Checks if the value of a passed URL parameter is valid */
 export function paramValid(val: unknown): val is string {
-    return typeof val === "string" && val.length > 0;
+  return typeof val === "string" && val.length > 0;
 }
 
 /**
@@ -16,58 +16,58 @@ export function paramValid(val: unknown): val is string {
  */
 export function respond(res: Response, type: ResponseType | number, data: Stringifiable | Record<string, unknown>, format = "json", matchesAmt?: number)
 {
-    let statusCode = 500;
-    let error = true;
-    let matches = null;
+  let statusCode = 500;
+  let error = true;
+  let matches = null;
 
-    let resData = {};
+  let resData = {};
 
-    if(typeof format !== "string" || !["json", "xml"].includes(format.toLowerCase()))
-        format = "json";
+  if(typeof format !== "string" || !["json", "xml"].includes(format.toLowerCase()))
+    format = "json";
 
-    format = format.toLowerCase();
+  format = format.toLowerCase();
 
-    switch(type)
+  switch(type)
+  {
+  case "success":
+    error = false;
+    matches = matchesAmt;
+    statusCode = 200;
+    resData = data;
+    break;
+  case "clientError":
+    error = true;
+    matches = matchesAmt ?? null;
+    statusCode = 400;
+    resData = { message: data };
+    break;
+  case "serverError":
+    error = true;
+    matches = matchesAmt ?? null;
+    statusCode = 500;
+    resData = { message: data };
+    break;
+  default:
+    if(typeof type === "number")
     {
-    case "success":
-        error = false;
-        matches = matchesAmt;
-        statusCode = 200;
-        resData = data;
-        break;
-    case "clientError":
-        error = true;
-        matches = matchesAmt ?? null;
-        statusCode = 400;
-        resData = { message: data };
-        break;
-    case "serverError":
-        error = true;
-        matches = matchesAmt ?? null;
-        statusCode = 500;
-        resData = { message: data };
-        break;
-    default:
-        if(typeof type === "number")
-        {
-            error = false;
-            matches = matchesAmt ?? 0;
-            statusCode = type;
-            resData = data;
-        }
-        break;
+      error = false;
+      matches = matchesAmt ?? 0;
+      statusCode = type;
+      resData = data;
     }
+    break;
+  }
 
-    resData = {
-        error,
-        ...(matches === undefined ? {} : { matches }),
-        ...resData,
-    };
+  resData = {
+    error,
+    ...(matches === undefined ? {} : { matches }),
+    ...resData,
+  };
 
-    const finalData = format === "xml" ? jsonToXml("data", resData) : resData;
-    const contentLen = byteLength(typeof finalData === "string" ? finalData : JSON.stringify(finalData));
+  const finalData = format === "xml" ? jsonToXml("data", resData) : resData;
+  const contentLen = byteLength(typeof finalData === "string" ? finalData : JSON.stringify(finalData));
 
-    res.setHeader("Content-Type", format === "xml" ? "application/xml" : "application/json");
-    contentLen > -1 && res.setHeader("Content-Length", contentLen);
-    res.status(statusCode).send(finalData);
+  res.setHeader("Content-Type", format === "xml" ? "application/xml" : "application/json");
+  contentLen > -1 && res.setHeader("Content-Length", contentLen);
+  res.status(statusCode).send(finalData);
 }

+ 31 - 30
test/latency-test.ts

@@ -3,45 +3,46 @@
 
 import "dotenv/config";
 import _axios from "axios";
+//@ts-ignore
 import percentile from "percentile";
 
 const settings = {
-    amount: 50,
-    url: `http://127.0.0.1:${process.env.HTTP_PORT}/search/top?q=pink guy - dog festival directions`,
+  amount: 50,
+  url: `http://127.0.0.1:${process.env.HTTP_PORT}/search/top?q=pink guy - dog festival directions`,
 };
 
 
 const axios = _axios.create({ timeout: 10_000 });
 
 async function run() {
-    console.log(`\n\n>>> Running latency test with ${settings.amount} requests...\n`);
-    const startTs = Date.now();
-
-    const times = [];
-    for(let i = 0; i < settings.amount; i++) {
-        const start = Date.now();
-        await axios.get(settings.url, {
-            headers: {
-                "Cache-Control": "no-cache",
-                Authorization: `Bearer ${process.env.AUTH_TOKENS!.split(",")[0]}`,
-            },
-        });
-        times.push(Date.now() - start);
-
-        i % 10 === 0 && i !== 0 && console.log(`Sent ${i} of ${settings.amount} requests`);
-    }
-
-    const avg = (times.reduce((a, c) => a + c, 0) / times.length).toFixed(0);
-    const perc80 = percentile(80, times);
-    const perc95 = percentile(95, times);
-    const perc99 = percentile(99, times);
-
-    console.log(`\n>>> Latency test finished after ${((Date.now() - startTs) / 1000).toFixed(2)}s`);
-    console.log(`avg:\t${avg}\tms`);
-    console.log(`80th%:\t${perc80}\tms`);
-    console.log(`95th%:\t${perc95}\tms`);
-    console.log(`99th%:\t${perc99}\tms`);
-    console.log();
+  console.log(`\n\n>>> Running latency test with ${settings.amount} requests...\n`);
+  const startTs = Date.now();
+
+  const times = [];
+  for(let i = 0; i < settings.amount; i++) {
+    const start = Date.now();
+    await axios.get(settings.url, {
+      headers: {
+        "Cache-Control": "no-cache",
+        Authorization: `Bearer ${process.env.AUTH_TOKENS!.split(",")[0]}`,
+      },
+    });
+    times.push(Date.now() - start);
+
+    i % 10 === 0 && i !== 0 && console.log(`Sent ${i} of ${settings.amount} requests`);
+  }
+
+  const avg = (times.reduce((a, c) => a + c, 0) / times.length).toFixed(0);
+  const perc80 = percentile(80, times);
+  const perc95 = percentile(95, times);
+  const perc99 = percentile(99, times);
+
+  console.log(`\n>>> Latency test finished after ${((Date.now() - startTs) / 1000).toFixed(2)}s`);
+  console.log(`avg:\t${avg}\tms`);
+  console.log(`80th%:\t${perc80}\tms`);
+  console.log(`95th%:\t${perc95}\tms`);
+  console.log(`99th%:\t${perc99}\tms`);
+  console.log();
 }
 
 run();