Преглед изворни кода

feat: add translations endpoint & preferLang param

Sv443 пре 2 година
родитељ
комит
d8c6135dd5
10 измењених фајлова са 369 додато и 204 уклоњено
  1. 5 0
      src/axios.ts
  2. 2 0
      src/constants.ts
  3. 9 5
      src/index.ts
  4. 7 0
      src/routes/index.ts
  5. 81 0
      src/routes/search.ts
  6. 37 0
      src/routes/translations.ts
  7. 4 142
      src/server.ts
  8. 60 25
      src/songData.ts
  9. 89 32
      src/types.d.ts
  10. 75 0
      src/utils.ts

+ 5 - 0
src/axios.ts

@@ -0,0 +1,5 @@
+import { default as _axios } from "axios";
+
+export const axios = _axios.create({
+    timeout: 1000 * 15,
+});

+ 2 - 0
src/constants.ts

@@ -0,0 +1,2 @@
+/** Set of all supported [ISO 639-1 language codes](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) */
+export const langCodes = new Set<string>(["aa","ab","ae","af","ak","am","an","ar","as","av","ay","az","ba","be","bg","bh","bi","bm","bn","bo","br","bs","ca","ce","ch","co","cr","cs","cu","cv","cy","da","de","dv","dz","ee","el","en","eo","es","et","eu","fa","ff","fi","fj","fo","fr","fy","ga","gd","gl","gn","gu","gv","ha","he","hi","ho","hr","ht","hu","hy","hz","ia","id","ie","ig","ii","ik","io","is","it","iu","ja","jv","ka","kg","ki","kj","kk","kl","km","kn","ko","kr","ks","ku","kv","kw","ky","la","lb","lg","li","ln","lo","lt","lu","lv","mg","mh","mi","mk","ml","mn","mr","ms","mt","my","na","nb","nd","ne","ng","nl","nn","no","nr","nv","ny","oc","oj","om","or","os","pa","pi","pl","ps","pt","qu","rm","rn","ro","ru","rw","sa","sc","sd","se","sg","si","sk","sl","sm","sn","so","sq","sr","ss","st","su","sv","sw","ta","te","tg","th","ti","tk","tl","tn","to","tr","ts","tt","tw","ty","ug","uk","ur","uz","ve","vi","vo","wa","wo","xh","yi","yo","za","zh","zu"]);

+ 9 - 5
src/index.ts

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

+ 7 - 0
src/routes/index.ts

@@ -0,0 +1,7 @@
+import { initSearchEndpoints } from "./search";
+import { initTranslationsEndpoints } from "./translations";
+
+export const endpointFuncs = [
+    initSearchEndpoints,
+    initTranslationsEndpoints,
+];

+ 81 - 0
src/routes/search.ts

@@ -0,0 +1,81 @@
+import { Application } from "express";
+import { paramValid, respond } from "../utils";
+import { getMeta } from "../songData";
+import { langCodes } from "../constants";
+
+export function initSearchEndpoints(app: Application) {
+    app.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;
+
+            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);
+
+                // 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");
+        }
+    });
+
+    app.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;
+
+            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);
+
+                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");
+        }
+    });
+}

+ 37 - 0
src/routes/translations.ts

@@ -0,0 +1,37 @@
+import { Application } from "express";
+import { paramValid, respond } from "../utils";
+import { getTranslations } from "../songData";
+import { langCodes } from "../constants";
+
+export function initTranslationsEndpoints(app: Application) {
+    app.get("/translations", (req, res) => {
+        const format: string = req.query.format ? String(req.query.format) : "json";
+
+        return respond(res, "clientError", "No song ID provided", format);
+    });
+
+    app.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;
+
+            if(!paramValid(songId) || isNaN(Number(songId)))
+                return respond(res, "clientError", "Provided song ID is invalid", format);
+
+            const translations = await getTranslations(Number(songId), { preferLang });
+
+            if(!translations)
+                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");
+        }
+    });
+}

+ 4 - 142
src/server.ts

@@ -5,13 +5,11 @@ import helmet from "helmet";
 import { RateLimiterMemory, RateLimiterRes } from "rate-limiter-flexible";
 import k from "kleur";
 import cors from "cors";
-import jsonToXml from "js2xmlparser";
 
 import packageJson from "../package.json";
 import { error } from "./error";
-import { getMeta } from "./songData";
-import type { ResponseType } from "./types";
-import type { Stringifiable } from "svcorelib";
+import { endpointFuncs } from "./routes";
+import { respond } from "./utils";
 
 const app = express();
 
@@ -86,78 +84,8 @@ function registerEndpoints()
             res.redirect(packageJson.homepage);
         });
 
-        const hasArg = (val: unknown) => typeof val === "string" && val.length > 0;
-
-        app.get("/search", async (req, res) => {
-            try
-            {
-                const { q, artist, song, format: fmt, threshold: thr } = req.query;
-
-                const format: string = fmt ? String(fmt) : "json";
-                const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
-
-                if(hasArg(q) || (hasArg(artist) && hasArg(song)))
-                {
-                    const meta = await getMeta({
-                        ...(q ? {
-                            q: String(q),
-                        } : {
-                            artist: String(artist),
-                            song: String(song),
-                        }),
-                        threshold,
-                    });
-
-                    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 } };
-
-                    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");
-            }
-        });
-
-        app.get("/search/top", async (req, res) => {
-            try
-            {
-                const { q, artist, song, format: fmt, threshold: thr } = req.query;
-
-                const format: string = fmt ? String(fmt) : "json";
-                const threshold = isNaN(Number(thr)) ? undefined : Number(thr);
-
-                if(hasArg(q) || (hasArg(artist) && hasArg(song)))
-                {
-                    const meta = await getMeta({
-                        ...(q ? {
-                            q: String(q),
-                        } : {
-                            artist: String(artist),
-                            song: String(song),
-                        }),
-                        threshold,
-                    });
-
-                    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");
-            }
-        });
+        for(const func of endpointFuncs)
+            func(app);
     }
     catch(err)
     {
@@ -165,72 +93,6 @@ function registerEndpoints()
     }
 }
 
-/**
- * Responds to an incoming request
- * @param type Type of response or status code
- * @param data The data to send in the response body
- * @param format json / xml
- */
-function respond(res: Response, type: ResponseType | number, data: Stringifiable | Record<string, unknown>, format = "json", matchesAmt = 0)
-{
-    let statusCode = 500;
-    let error = true;
-    let matches = null;
-
-    let resData = {};
-
-    if(typeof format !== "string" || !["json", "xml"].includes(format.toLowerCase()))
-        format = "json";
-
-    format = format.toLowerCase();
-
-    switch(type)
-    {
-        case "success":
-            error = false;
-            matches = matchesAmt;
-            statusCode = 200;
-            resData = typeof data === "string" ? data : { ...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 = typeof data === "string" ? data : { ...data };
-            }
-            break;
-    }
-
-    const mimeType = format !== "xml" ? "application/json" : "application/xml";
-
-    resData = {
-        error,
-        matches,
-        ...resData,
-        timestamp: Date.now(),
-    };
-
-    const finalData = format === "xml" ? jsonToXml.parse("data", resData) : resData;
-
-    res.setHeader("Content-Type", mimeType);
-    res.status(statusCode)
-        .send(finalData);
-}
-
 function getAuthTokens() {
     const envVal = process.env["AUTH_TOKENS"];
     let tokens: string[] = [];

+ 60 - 25
src/songData.ts

@@ -1,40 +1,28 @@
-import axios from "axios";
+/* eslint-disable no-control-regex */
+
+import { axios } from "./axios";
 import Fuse from "fuse.js";
 import { nanoid } from "nanoid";
-import { allOfType, clamp } from "svcorelib";
-import type { ApiSearchResult, SongMeta } from "./types";
-
-type MetaSearchHit = SongMeta & { uuid?: string; };
-
-interface GetMetaProps {
-    q?: string;
-    artist?: string;
-    song?: string;
-    threshold?: number;
-}
-
-interface GetMetaResult {
-    top: SongMeta;
-    all: SongMeta[];
-}
+import { clamp, Stringifiable } from "svcorelib";
+import type { 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 Pass an object with either a `q` prop or the props `artist` and `song` to make use of fuzzy filtering
+ * @param param0 URL parameters - needs either a `q` prop or the props `artist` and `song`
  */
 export async function getMeta({
     q,
     artist,
     song,
     threshold,
-}: GetMetaProps): Promise<GetMetaResult | null>
+    preferLang,
+}: GetMetaArgs): Promise<GetMetaResult | null>
 {
     const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
 
     const query = q ? q : `${artist} ${song}`;
-    const searchByQuery = allOfType([artist, song], "undefined");
 
     const { data: { response }, status } = await axios.get<ApiSearchResult>(`https://api.genius.com/search?q=${encodeURIComponent(query)}`, {
         headers: { "Authorization": `Bearer ${accessToken}` },
@@ -145,25 +133,72 @@ export async function getMeta({
             })
             .filter(h => h !== undefined) as MetaSearchHit[];
 
+        if(hits.length === 0)
+            return null;
+
+        const preferredBestMatches: MetaSearchHit[] = [];
+
+        // splice out preferredLang results and move them to the beginning of the array
+        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: hits[0] as MetaSearchHit,
-            all: hits.slice(0, 10),
+            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> {
+    const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
+
+    const { data: { response: { song } }, status } = await axios.get<ApiSongResult>(`https://api.genius.com/songs/${songId}`, {
+        headers: { "Authorization": `Bearer ${accessToken}` },
+    });
+
+    if(status >= 200 && status < 300 && Array.isArray(song?.translation_songs))
+    {
+        const results = song.translation_songs
+            .map(({ language, id, path, title, url }) => ({ language, id, path, title, url }));
+
+        const preferredResults: SongTranslation[] = [];
+
+        // splice out preferredLang results and move them to the beginning of the array
+        if(preferLang) {
+            results.forEach((res, i) => {
+                if(res.language === preferLang.toLowerCase())
+                    preferredResults.push(results.splice(i, 1)[0]!);
+            });
+        }
+
+        return preferredResults.concat(results);
+    }
+    return null;
+}
+
 /**
  * Removes invisible characters and control characters from a string  
  * @throws Throws TypeError if the input is not a string
  */
-function formatStr(str: unknown): string
+function formatStr(str: Stringifiable): string
 {
-    if(!str || typeof str !== "string")
+    if(!str || !str.toString || typeof str !== "string")
         throw new TypeError("formatStr(): input is not a string");
 
-    return str
+    return str.toString()
         .replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "") // 0-width spaces & control characters
         .replace(/\u00A0/g, " "); // non-standard 1-width spaces
 }

+ 89 - 32
src/types.d.ts

@@ -32,6 +32,35 @@ export interface SongMeta {
     id: number;
 }
 
+export type MetaSearchHit = SongMeta & { uuid?: string; };
+
+export interface GetMetaArgs {
+    q?: string;
+    artist?: string;
+    song?: string;
+    threshold?: number;
+    preferLang?: string;
+}
+
+export interface GetMetaResult {
+    top: SongMeta;
+    all: SongMeta[];
+}
+
+//#SECTION translations
+
+export interface SongTranslation {
+    language: string;
+    id: number;
+    path: string;
+    title: string;
+    url: string;
+}
+
+export interface GetTranslationsArgs {
+    preferLang?: string;
+}
+
 //#SECTION server
 
 export type ResponseType = "serverError" | "clientError" | "success";
@@ -47,46 +76,74 @@ export type ApiSearchResult = {
     };
 };
 
+/** The entire object returned by the songs endpoint of the genius API */
+export type ApiSongResult = {
+    response: {
+        song: SongObj;
+    }
+}
+
 /** One result returned by the genius API search */
 export type SearchHit = {
     type: "song";
-    result: {
-        artist_names: string;
-        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;
+    result: SongBaseObj & {
         release_date_components: {
             year: number;
             month: number;
             day: number;
         };
-        song_art_image_thumbnail_url: string;
-        song_art_image_url: string;
+        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;
-        title_with_featured: string;
         url: string;
-        featured_artists: {
-            api_path: string;
-            header_image_url: string;
-            id: number;
-            image_url: string;
-            name: string;
-            url: string;
-        }[];
-        primary_artist: {
-            api_path: string;
-            header_image_url: string;
-            id: number;
-            image_url: string;
-            name: 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;
+}

+ 75 - 0
src/utils.ts

@@ -0,0 +1,75 @@
+import { Response } from "express";
+import { Stringifiable } from "svcorelib";
+import jsonToXml from "js2xmlparser";
+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;
+}
+
+/**
+ * Responds to an incoming request
+ * @param type Type of response or status code
+ * @param data The data to send in the response body
+ * @param format json / xml
+ */
+export function respond(res: Response, type: ResponseType | number, data: Stringifiable | Record<string, unknown>, format = "json", matchesAmt = 0)
+{
+    let statusCode = 500;
+    let error = true;
+    let matches = null;
+
+    let resData = {};
+
+    if(typeof format !== "string" || !["json", "xml"].includes(format.toLowerCase()))
+        format = "json";
+
+    format = format.toLowerCase();
+
+    switch(type)
+    {
+    case "success":
+        error = false;
+        matches = matchesAmt;
+        statusCode = 200;
+        resData = typeof data === "string" ? data : { ...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 = typeof data === "string" ? data : { ...data };
+        }
+        break;
+    }
+
+    const mimeType = format !== "xml" ? "application/json" : "application/xml";
+
+    resData = {
+        error,
+        matches,
+        ...resData,
+        timestamp: Date.now(),
+    };
+
+    const finalData = format === "xml" ? jsonToXml.parse("data", resData) : resData;
+
+    res.setHeader("Content-Type", mimeType);
+    res.status(statusCode)
+        .send(finalData);
+}