Procházet zdrojové kódy

Merge pull request #13 from Sv443/feat/v1.3

Sven Fehler před 2 roky
rodič
revize
ba1a78710d
23 změnil soubory, kde provedl 6867 přidání a 2319 odebrání
  1. 0 21
      .editorconfig
  2. 8 4
      .env.template
  3. 28 14
      .eslintrc.js
  4. 45 0
      .github/workflows/codeql.yml
  5. 4 2
      .gitignore
  6. 6 3
      .vscode/settings.json
  7. 171 18
      README.md
  8. 10 1
      changelog.md
  9. 5885 1862
      package-lock.json
  10. 7 3
      package.json
  11. 11 0
      src/axios.ts
  12. 11 0
      src/constants.ts
  13. 10 5
      src/index.ts
  14. 35 0
      src/routes/album.ts
  15. 24 0
      src/routes/index.ts
  16. 81 0
      src/routes/search.ts
  17. 37 0
      src/routes/translations.ts
  18. 38 172
      src/server.ts
  19. 253 0
      src/songData.ts
  20. 0 169
      src/songMeta.ts
  21. 118 32
      src/types.d.ts
  22. 72 0
      src/utils.ts
  23. 13 13
      tsconfig.json

+ 0 - 21
.editorconfig

@@ -1,21 +0,0 @@
-root = true
-
-[*]
-indent_style = space
-tab_width = 4
-indent_size = 4
-charset = utf-8
-trim_trailing_whitespace = false
-insert_final_newline = true
-curly_bracket_next_line = true
-spaces_around_operators = true
-
-# 2-space indentation
-[{package.json,package-lock.json,**.yml}]
-indent_style = space
-indent_size = 2
-tab_width = 2
-
-# no final newline
-[{**.json}]
-insert_final_newline = false

+ 8 - 4
.env.template

@@ -1,4 +1,8 @@
-HTTP_PORT=8074             # 
-HTTP_HOST=0.0.0.0          # Defaults to 0.0.0.0 (listen on all interfaces)
-GENIUS_ACCESS_TOKEN=abcdef # Gotten from POST https://api.genius.com/oauth/token or from creating a client on https://genius.com/api-clients
-AUTH_TOKENS=               # Comma-separated list of HTTP bearer tokens that are excluded from rate limiting
+# Default is 8074
+HTTP_PORT=8074
+# Defaults to 0.0.0.0 (listen on all interfaces)
+HTTP_HOST=
+# Gotten from creating a client on https://genius.com/api-clients
+GENIUS_ACCESS_TOKEN=
+# Comma-separated list of HTTP bearer tokens that are excluded from rate limiting (on geniURL's side)
+AUTH_TOKENS=

+ 28 - 14
.eslintrc.js

@@ -1,19 +1,33 @@
 module.exports = {
-    "env": {
-        "commonjs": true,
-        "es2021": true,
-        "node": true,
+    env: {
+        es6: true,
+        node: true,
     },
-    "extends": "eslint:recommended",
-    "parserOptions": {
-        "ecmaVersion": "latest",
+    extends: [
+        "eslint:recommended",
+        "plugin:@typescript-eslint/recommended",
+    ],
+    parser: "@typescript-eslint/parser",
+    parserOptions: {
+        ecmaVersion: "latest",
+        sourceType: "module",
     },
-    "rules": {
-        "quotes": [ "error" , "double" ],
-        "semi": [ "error" , "always" ],
-        "comma-dangle": [ "error" , "always-multiline" ],
-        "array-bracket-newline": [ "error", "consistent" ],
-        "function-paren-newline": [ "error", "multiline" ],
-        "no-control-regex": ["off"],
+    plugins: [
+        "@typescript-eslint",
+    ],
+    ignorePatterns: [
+        "out/**",
+        "test.*",
+    ],
+    rules: {
+        "quotes": [ "error", "double" ],
+        "semi": [ "error", "always" ],
+        "eol-last": [ "error", "always" ],
+        "no-async-promise-executor": "off",
+        // see https://github.com/eslint/eslint/issues/14538#issuecomment-862280037
+        "indent": ["error", 4, { "ignoredNodes": ["VariableDeclaration[declarations.length=0]"] }],
+        "@typescript-eslint/no-non-null-assertion": "off",
+        "@typescript-eslint/no-unused-vars": ["warn", { "ignoreRestSiblings": true, "argsIgnorePattern": "^_" }],
+        "comma-dangle": ["error", "only-multiline"],
     },
 };

+ 45 - 0
.github/workflows/codeql.yml

@@ -0,0 +1,45 @@
+name: "CodeQL"
+
+on:
+  push:
+    branches: [ main ]
+  pull_request:
+    # The branches below must be a subset of the branches above
+    branches: [ main ]
+  # schedule:
+  #   - cron: '23 12 * * 4'
+
+jobs:
+  analyze:
+    name: Analyze Code
+    runs-on: ubuntu-latest
+    permissions:
+      actions: read
+      contents: read
+      security-events: write
+
+    strategy:
+      matrix:
+        language: [ 'javascript' ]
+        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ]
+        # Learn more:
+        # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed
+
+    steps:
+    - name: Checkout repository and submodules
+      uses: actions/checkout@v3
+      with:
+        submodules: recursive
+
+    # Initializes the CodeQL tools for scanning.
+    - name: Initialize CodeQL
+      uses: github/codeql-action/init@v2
+      with:
+        languages: ${{ matrix.language }}
+        # If you wish to specify custom queries, you can do so here or in a config file.
+        # By default, queries listed here will override any specified in a config file.
+        # Prefix the list here with "+" to use these queries and those in the config file.
+        # queries: ./path/to/local/query, your-org/your-repo/queries@main
+
+    - name: Perform CodeQL Analysis
+      uses: github/codeql-action/analyze@v2

+ 4 - 2
.gitignore

@@ -1,6 +1,8 @@
-node_modules/
+.env
+
 test.js
 test.ts
-.env
+
+node_modules/
 *.log
 out/

+ 6 - 3
.vscode/settings.json

@@ -1,8 +1,11 @@
 {
     "editor.codeActionsOnSave": {
-        "source.fixAll.eslint": true
+        "source.fixAll.eslint": true,
     },
     "eslint.validate": [
-        "javascript"
-    ]
+        "javascript",
+    ],
+    "files.associations": {
+        ".env.template": "dotenv",
+    },
 }

+ 171 - 18
README.md

@@ -1,9 +1,9 @@
 # geniURL
-
-Simple JSON and XML REST API to search for song metadata and the lyrics URL on [genius.com](https://genius.com/)  
+Simple JSON and XML REST API to search for song metadata, the lyrics URL and lyrics translations on [genius.com](https://genius.com/)  
 Authorization is not required and geniURL implements a fuzzy search that will greatly improve search results over the genius.com API.  
+Obtaining actual lyrics sadly isn't possible.  
   
-Obtaining actual lyrics sadly isn't possible due to licensing and copyright reasons.  
+Like geniURL? Please consider [supporting the development ❤️](https://github.com/sponsors/Sv443)
 
 <br><br>
 
@@ -14,22 +14,25 @@ To download it and test locally, hover over the collection, click the three-dot-
 <br><br>
 
 ## Base URL:
-
 I host a public instance on this URL:
-
 ```
 https://api.sv443.net/geniurl/
 ```
 
-Note that this instance is rate limited to 5 requests in 10 seconds.  
-<sub>If you want to host your own and increase the values, look at the top of `src/server.js`</sub>
+<sub>
+Note that this instance is rate limited to 10 requests within 30 seconds per unique client.
+</sub>
 
 <br><br>
 
 ## Routes:
-
 All routes support gzip and deflate compression.
 
+- [Search](#get-search)
+    - [Search (only top result)](#get-searchtop)
+- [Translations](#get-translationssongid)
+- [Associated Album](#get-albumsongid)
+
 <br>
 
 > ### GET `/search`
@@ -41,20 +44,25 @@ All routes support gzip and deflate compression.
 >
 > **URL Parameters:**  
 > `?q=search%20query`  
-> This parameter should contain both the song and artist name. For best result artist name should come first, separate with a hyphen (`-`)  
+> This parameter should contain both the song and artist name. For best result artist name should come first, separate with a whitespace or hyphen.  
 > Sometimes the song name alone might be enough but the results vary greatly.  
-> Using this parameter instead of `?artist` and `?song` means you will get slightly less accurate results.  
+> If you want to search for a remix (like `?q=Artist - Song (Artist2 Remix)`), this parameter will yield better results.  
 > Make sure the search query is [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
 > 
 > **OR**
 > 
 > `?artist=name` and `?song=name`  
-> Instead of `?q`, you can use `?artist` and `?song` to help geniURL filter the search results better, so your top results will be more accurate.  
-> Make sure these parameters are [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
+> Instead of `?q`, you can also use `?artist` and `?song`.  
+> Make sure these parameters are [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)
 > 
 > <br>
 > 
 > **Optional URL Parameters:**  
+> `?preferLang=en`  
+> Sometimes the genius API and geniURL's filtering will rank song translations or remixes higher than the original song. This optional parameter can help with that.  
+> Specify a [two-character ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) here to tell geniURL to prefer results of that language.  
+> Note that this only changes the ranking. Filtering will stay the same and results of other languages will still be returned.
+>   
 > `?format=json/xml`  
 > Use this optional parameter to change the response format from the default (`json`) to `xml`  
 > The structure of the XML data is similar to the shown JSON data.
@@ -137,7 +145,7 @@ All routes support gzip and deflate compression.
 >
 > ```json
 > {
->     "error": false,
+>     "error": true,
 >     "matches": 0,
 >     "message": "Found no results matching your search query",
 >     "timestamp": 1234567890123
@@ -157,20 +165,25 @@ All routes support gzip and deflate compression.
 >
 > **URL Parameters:**  
 > `?q=search%20query`  
-> This parameter should contain both the song and artist name. For best result artist name should come first, separate with a hyphen (`-`)  
+> This parameter should contain both the song and artist name. For best result artist name should come first, separate with a whitespace or hyphen.  
 > Sometimes the song name alone might be enough but the results vary greatly.  
-> Using this parameter instead of `?artist` and `?song` means you will get slightly less accurate results.  
+> If you want to search for a remix (like `?q=Artist - Song (Artist2 Remix)`), this parameter will yield better results.  
 > Make sure the search query is [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
 > 
 > **OR**
 > 
 > `?artist=name` and `?song=name`  
-> Instead of `?q`, you can use `?artist` and `?song` to help geniURL filter the search results better, so your top results will be more accurate.  
-> Make sure these parameters are [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
+> Instead of `?q`, you can also use `?artist` and `?song`.  
+> Make sure these parameters are [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)
 > 
 > <br><br>
 > 
 > **Optional URL Parameters:**  
+> `?preferLang=en`  
+> Sometimes the genius API and geniURL's filtering will rank song translations or remixes higher than the original song. This optional parameter can help with that.  
+> Specify a [two-character ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) here to tell geniURL to prefer results of that language.  
+> Note that this only changes the ranking. Filtering will stay the same and results of other languages will still be returned.
+>   
 > `?format=json/xml`  
 > Use this optional parameter to change the response format from the default (`json`) to `xml`  
 > The structure of the XML data is similar to the shown JSON data.
@@ -246,7 +259,7 @@ All routes support gzip and deflate compression.
 >
 > ```json
 > {
->     "error": false,
+>     "error": true,
 >     "matches": 0,
 >     "message": "Found no results matching your search query",
 >     "timestamp": 1234567890123
@@ -257,6 +270,146 @@ All routes support gzip and deflate compression.
 
 <br><br>
 
+> ### GET `/translations/:songId`
+>
+> This endpoint returns all translations of a certain song.  
+> Specify the song ID, gotten from the /search/ endpoints, in the URL path.  
+> Example: `/translations/3093344`
+> 
+> <br>
+>
+> **Optional URL Parameters:**  
+> `?preferLang=en`  
+> The filtering done by the genius API and geniURL will sometimes produce results that are ranked inconsistently. This optional parameter can help with that.  
+> Specify a [two-character ISO 639-1 language code](https://en.wikipedia.org/wiki/List_of_ISO_639-1_codes) here to tell geniURL to prefer results of that language.  
+> Note that this only changes the ranking. Filtering will stay the same and results of other languages will still be returned.
+>   
+> `?format=json/xml`  
+> Use this optional parameter to change the response format from the default (`json`) to `xml`  
+> The structure of the XML data is similar to the shown JSON data.
+> 
+> <br>
+> 
+> **Response:**  
+> 
+> <details><summary><b>Successful response (click to view)</b></summary>
+>
+> ```jsonc
+> {
+>     "error": false,
+>     "matches": 1,
+>     "translations": [
+>         {
+>             "language": "es",
+>             "title": "Artist - Song (Traducción al Español)",
+>             "url": "https://genius.com/Genius-traducciones-al-espanol-artist-song-al-espanol-lyrics",
+>             "path": "/Genius-traducciones-al-espanol-artist-song-al-espanol-lyrics",
+>             "id": 6942
+>         }
+>     ],
+>     "timestamp": 1234567890123
+> }
+> ```
+>
+> </details>
+> <br>
+> <details><summary>Errored response (click to view)</summary>
+>
+> ```json
+> {
+>     "error": true,
+>     "matches": null,
+>     "message": "Something went wrong",
+>     "timestamp": 1234567890123
+> }
+> ```
+>
+> </details>
+> <br>
+> <details><summary>Response when no result found (click to view)</summary>
+>
+> ```json
+> {
+>     "error": false,
+>     "matches": 0,
+>     "translations": [],
+>     "timestamp": 1234567890123
+> }
+> ```
+>
+> </details><br>
+
+<br><br>
+
+> ### GET `/album/:songId`
+>
+> This endpoint returns any associated album for a specified song.  
+> Example: `/translations/3093344`
+> 
+> <br>
+>
+> **Optional URL Parameters:**  
+> `?format=json/xml`  
+> Use this optional parameter to change the response format from the default (`json`) to `xml`  
+> The structure of the XML data is similar to the shown JSON data.
+> 
+> <br>
+> 
+> **Response:**  
+> 
+> <details><summary><b>Successful response (click to view)</b></summary>
+>
+> ```jsonc
+> {
+>     "error": false,
+>     "matches": 1,
+>     "album": {
+>         "name": "Album",
+>         "fullTitle": "Song by Artist",
+>         "url": "https://genius.com/albums/Artist/Album",
+>         "coverArt": "https://images.genius.com/...",
+>         "id": 12345,
+>         "artist": {
+>             "name": "Artist",
+>             "url": "https://genius.com/artists/Artist",
+>             "image": "https://images.genius.com/...",
+>             "headerImage": "https://images.genius.com/..."
+>         }
+>     },
+>     "timestamp": 1234567890123
+> }
+> ```
+>
+> </details>
+> <br>
+> <details><summary>Errored response (click to view)</summary>
+>
+> ```json
+> {
+>     "error": true,
+>     "matches": null,
+>     "message": "Something went wrong",
+>     "timestamp": 1234567890123
+> }
+> ```
+>
+> </details>
+> <br>
+> <details><summary>Response when no result found (click to view)</summary>
+>
+> ```json
+> {
+>     "error": true,
+>     "matches": 0,
+>     "message": "Couldn't find any associated album for this song",
+>     "timestamp": 1234567890123
+> }
+> ```
+>
+> </details><br>
+
+<br><br><br>
+
 <div align="center" style="text-align:center;">
 
 Made with ❤️ by [Sv443](https://sv443.net/)  

+ 10 - 1
changelog.md

@@ -1,5 +1,6 @@
 ## Version History:
-- [**1.2.0**](#v120)
+- [**1.3.0**](#v130)
+- [1.2.0](#v120)
 - [1.1.1](#v111)
 - [1.1.0](#v110)
 - [1.0.0](#v100)
@@ -8,6 +9,14 @@
 
 <br><br>
 
+### v1.3.0
+- Added route `/translations/:songId` to receive info about a song's translation pages
+- Added route `/album/:songId` to get info about the album that the provided song is in
+- Added parameter `?preferLang=en` to always rank results of a certain language higher than the rest
+- geniURL will now replace inconsistent unicode characters in the properties `title`, `fullTitle`, and `artists` ([#15](https://github.com/Sv443/geniURL/issues/15))
+
+<br>
+
 ### v1.2.0
 - Added `?threshold` parameter to change the fuzzy search threshold from its default of 0.6 ([#7](https://github.com/Sv443/geniURL/issues/7))
 - Added support for fuzzy searching when using `?q` instead of `?artist` and `?song` ([#8](https://github.com/Sv443/geniURL/issues/8))

Rozdílová data souboru nebyla zobrazena, protože soubor je příliš velký
+ 5885 - 1862
package-lock.json


+ 7 - 3
package.json

@@ -1,7 +1,7 @@
 {
   "name": "geniurl",
-  "version": "1.2.0",
-  "description": "Simple JSON and XML REST API to search for song metadata and the lyrics URL on genius.com",
+  "version": "1.3.0",
+  "description": "Simple JSON and XML REST API to search for song metadata, the lyrics URL and lyrics translations on genius.com",
   "main": "src/index.ts",
   "scripts": {
     "start": "tsc && node --enable-source-maps out/src/index.js",
@@ -51,9 +51,13 @@
     "@types/express": "^4.17.14",
     "@types/node": "^18.11.3",
     "@types/tcp-port-used": "^1.0.1",
+    "@typescript-eslint/eslint-plugin": "^5.48.1",
+    "@typescript-eslint/parser": "^5.48.1",
     "dotenv": "^16.0.0",
     "eslint": "^8.9.0",
+    "jest": "^29.3.1",
     "nodemon": "^2.0.20",
+    "ts-node": "^10.9.1",
     "tslib": "^2.4.0"
   }
-}
+}

+ 11 - 0
src/axios.ts

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

+ 11 - 0
src/constants.ts

@@ -0,0 +1,11 @@
+/** 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"]);
+
+/** Map of unicode variant characters and replacements used in normalizing fields before fuzzy filtering them */
+export const charReplacements = new Map<string, string>([
+    ["`´’︐︑ʻ", "'"],
+    ["“”", "\""],
+    [",", ","],
+    ["—─ ", "-"],
+    ["    ", " "],
+]);

+ 10 - 5
src/index.ts

@@ -5,20 +5,25 @@ 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 = [
+            "HTTP_PORT",
+            "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);
     }
 }
 

+ 35 - 0
src/routes/album.ts

@@ -0,0 +1,35 @@
+import { Router } from "express";
+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";
+
+        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;
+
+            const format: string = fmt ? String(fmt) : "json";
+
+            if(!paramValid(songId) || isNaN(Number(songId)))
+                return respond(res, "clientError", "Provided song ID is invalid", format);
+
+            const album = await getAlbum(Number(songId));
+
+            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");
+        }
+    });
+}

+ 24 - 0
src/routes/index.ts

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

+ 81 - 0
src/routes/search.ts

@@ -0,0 +1,81 @@
+import { Router } from "express";
+import { paramValid, respond } from "../utils";
+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;
+
+            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");
+        }
+    });
+
+    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;
+
+            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 { Router } from "express";
+import { paramValid, respond } from "../utils";
+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";
+
+        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;
+
+            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");
+        }
+    });
+}

+ 38 - 172
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 "./songMeta";
-import type { ResponseType } from "./types";
-import type { Stringifiable } from "svcorelib";
+import { initRouter } from "./routes";
+import { respond } from "./utils";
 
 const app = express();
 
@@ -20,9 +18,11 @@ app.use(helmet());
 app.use(express.json());
 app.use(compression());
 
+app.disable("x-powered-by");
+
 const rateLimiter = new RateLimiterMemory({
-    points: 5,
-    duration: 10,
+    points: 10,
+    duration: 30,
 });
 
 const authTokens = getAuthTokens();
@@ -44,33 +44,40 @@ export async function init()
             return next();
     });
 
-    const listener = app.listen(port, host, () => {
-        app.disable("x-powered-by");
+    // 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})`);
 
-        // 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;
+        if(authHeader && authTokens.has(authHeader))
+            return next();
 
-            res.setHeader("API-Info", `geniURL v${packageJson.version} (${packageJson.homepage})`);
+        const setRateLimitHeaders = (rateLimiterRes: RateLimiterRes) => {
+            res.setHeader("Retry-After", 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());
+        };
 
-            if(authHeader && authTokens.has(authHeader))
+        rateLimiter.consume(req.ip)
+            .then((rateLimiterRes: RateLimiterRes) => {
+                setRateLimitHeaders(rateLimiterRes);
                 return next();
+            })
+            .catch((err) => {
+                if(err instanceof RateLimiterRes) {
+                    setRateLimitHeaders(err);
+                    return respond(res, 429, { message: "You are being rate limited. Please try again a little later." }, fmt);
+                }
+                else return respond(res, 500, { message: "Encountered an internal error. Please try again a little later." }, fmt);
+            });
+    });
 
-            rateLimiter.consume(req.ip)
-                .catch((err) => {
-                    if(err instanceof RateLimiterRes) {
-                        res.set("Retry-After", String(Math.ceil(err.msBeforeNext / 1000)));
-                        return respond(res, 429, { message: "You are being rate limited" }, fmt);
-                    }
-                    else
-                        return respond(res, 500, { message: "Internal error in rate limiting middleware. Please try again later." }, fmt);
-                })
-                .finally(next);
-        });
-
-        registerEndpoints();
+    const listener = app.listen(port, host, () => {
+        registerRoutes();
 
         console.log(k.green(`Listening on ${host}:${port}`));
     });
@@ -78,159 +85,18 @@ export async function init()
     listener.on("error", (err) => error("General server error", err, true));
 }
 
-function registerEndpoints()
+function registerRoutes()
 {
     try
     {
-        app.get("/", (_req, res) => {
-            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");
-            }
-        });
+        initRouter(app);
     }
     catch(err)
     {
-        error("Error while registering endpoints", err instanceof Error ? err : undefined, true);
+        error("Error while initializing router", err instanceof Error ? err : undefined, true);
     }
 }
 
-/**
- * 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[] = [];

+ 253 - 0
src/songData.ts

@@ -0,0 +1,253 @@
+/* eslint-disable no-control-regex */
+
+import Fuse from "fuse.js";
+import { nanoid } from "nanoid";
+import { clamp } from "svcorelib";
+
+import { axios, getAxiosAuthConfig } from "./axios";
+import { charReplacements } from "./constants";
+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 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,
+        };
+
+        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 { 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,
+                }
+            };
+        }
+        return null;
+    }
+    catch(e) {
+        return null;
+    }
+}
+
+const allReplaceCharsRegex = new RegExp(`[${
+    [...charReplacements.entries()].reduce((a, [chars]) => a + chars, "")
+}]`);
+
+const charReplacementRegexes = [...charReplacements.entries()]
+    .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
+}

+ 0 - 169
src/songMeta.ts

@@ -1,169 +0,0 @@
-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[];
-}
-
-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
- */
-export async function getMeta({
-    q,
-    artist,
-    song,
-    threshold,
-}: GetMetaProps): 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}` },
-    });
-
-    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[];
-
-        return {
-            top: hits[0] as MetaSearchHit,
-            all: hits.slice(0, 10),
-        };
-    }
-
-    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
-{
-    if(!str || typeof str !== "string")
-        throw new TypeError("formatStr(): input is not a string");
-
-    return str
-        .replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "") // 0-width spaces & control characters
-        .replace(/\u00A0/g, " "); // non-standard 1-width spaces
-}

+ 118 - 32
src/types.d.ts

@@ -1,3 +1,20 @@
+//#SECTION server
+
+export type ServerResponse<T> = SuccessResponse<T> | ErrorResponse;
+
+export type SuccessResponse<T> = {
+    error: false;
+    matches: number;
+    timestamp: number;
+} & T;
+
+export type ErrorResponse = {
+    error: true;
+    matches: 0 | null;
+    message: string;
+    timestamp: number;
+}
+
 //#SECTION meta
 
 interface Artist {
@@ -32,6 +49,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 +93,86 @@ 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;
+}
+
+export interface Album {
+    name: string;
+    fullTitle: string;
+    url: string;
+    coverArt: string | null;
+    id: number;
+    artist: Artist;
+}
+
+//#SECTION internal
+export type SupportedMethods = "GET";

+ 72 - 0
src/utils.ts

@@ -0,0 +1,72 @@
+import { Response } from "express";
+import { Stringifiable } from "svcorelib";
+import { parse as 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?: number)
+{
+    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;
+    }
+
+    resData = {
+        error,
+        ...(matches === undefined ? {} : { matches }),
+        ...resData,
+        timestamp: Date.now(),
+    };
+
+    const finalData = format === "xml" ? jsonToXml("data", resData) : resData;
+
+    res.setHeader("Content-Type", format === "xml" ? "application/xml" : "application/json");
+    res.status(statusCode).send(finalData);
+}

+ 13 - 13
tsconfig.json

@@ -1,22 +1,22 @@
 {
   "compilerOptions": {
-    "baseUrl": ".",
-    "target": "es2022",
-    "module": "CommonJS",
-    "rootDir": ".",
-    "outDir": "./out/",
-    "moduleResolution": "node",
-    "sourceMap": true,
-    "useDefineForClassFields": true,
     "allowJs": false,
-    "importHelpers": true,
-    "skipLibCheck": true,
-    "esModuleInterop": true,
     "allowSyntheticDefaultImports": true,
-    "strict": true,
+    "baseUrl": ".",
+    "esModuleInterop": true,
     "forceConsistentCasingInFileNames": true,
-    "resolveJsonModule": true,
+    "importHelpers": true,
     "isolatedModules": true,
+    "module": "CommonJS",
+    "moduleResolution": "node",
     "noUncheckedIndexedAccess": true,
+    "outDir": "./out/",
+    "resolveJsonModule": true,
+    "rootDir": ".",
+    "skipLibCheck": true,
+    "sourceMap": true,
+    "strict": true,
+    "target": "es2022",
+    "useDefineForClassFields": true,
   }
 }

Některé soubory nejsou zobrazeny, neboť je v těchto rozdílových datech změněno mnoho souborů