Ver código fonte

Merge pull request #5 from Sv443/feat/ts_migration

Sven Fehler 2 anos atrás
pai
commit
b1656bdf5e
15 arquivos alterados com 1128 adições e 217 exclusões
  1. 2 0
      .gitignore
  2. 10 12
      .vscode/launch.json
  3. 10 0
      .vscode/tasks.json
  4. 36 4
      README.md
  5. 9 1
      changelog.md
  6. 748 3
      package-lock.json
  7. 12 4
      package.json
  8. 0 17
      src/error.js
  9. 16 0
      src/error.ts
  10. 4 4
      src/index.ts
  11. 56 51
      src/server.ts
  12. 0 113
      src/songMeta.js
  13. 132 0
      src/songMeta.ts
  14. 71 8
      src/types.d.ts
  15. 22 0
      tsconfig.json

+ 2 - 0
.gitignore

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

+ 10 - 12
.vscode/launch.json

@@ -5,24 +5,22 @@
     "version": "0.2.0",
     "configurations": [
         {
-            "type": "pwa-node",
+            "type": "node",
             "request": "launch",
             "name": "Launch",
-            "skipFiles": [
-                "<node_internals>/**"
-            ],
-            "program": "${workspaceFolder}\\src\\index.js",
+            "program": "${workspaceFolder}/src/index.ts",
+            "preLaunchTask": "prelaunch",
+            "outFiles": ["${workspaceFolder}/out/**/*.js"],
             "console": "integratedTerminal"
         },
         {
-            "type": "pwa-node",
+            "type": "node",
             "request": "launch",
-            "name": "test.js",
-            "skipFiles": [
-                "<node_internals>/**"
-            ],
-            "program": "${workspaceFolder}\\test.js",
+            "name": "test.ts",
+            "program": "${workspaceFolder}/src/test.ts",
+            "preLaunchTask": "prelaunch",
+            "outFiles": ["${workspaceFolder}/out/**/*.js"],
             "console": "integratedTerminal"
-        }
+        },
     ]
 }

+ 10 - 0
.vscode/tasks.json

@@ -0,0 +1,10 @@
+{
+    "version": "2.0.0",
+    "tasks": [
+        {
+            "label": "prelaunch",
+            "type": "shell",
+            "command": "tsc"
+        }
+    ]
+}

+ 36 - 4
README.md

@@ -57,13 +57,29 @@ All routes support gzip and deflate compression.
 >     "top": {
 >         "url": "https://genius.com/Artist-1-song-name-lyrics",
 >         "path": "/Artist-1-song-name-lyrics",
+>         "language": "en",
 >         "meta": {
 >             "title": "Song Name",
 >             "fullTitle": "Song Name by Artist 1 (ft. Artist 2)",
 >             "artists": "Artist 1 (ft. Artist 2)",
 >             "primaryArtist": {
 >                 "name": "Artist 1",
->                 "url": "https://genius.com/artists/Artist-1"
+>                 "url": "https://genius.com/artists/Artist-1",
+>                 "headerImage": "https://images.genius.com/...",
+>                 "image": "https://images.genius.com/..."
+>             },
+>             "featuredArtists": [
+>                 {
+>                     "name": "Featured Artist 1",
+>                     "url": "https://genius.com/artists/Featured-Artist-1",
+>                     "headerImage": "https://images.genius.com/...",
+>                     "image": "https://images.genius.com/..."
+>                 }
+>             ],
+>             "releaseDate": {
+>                 "year": 2018,
+>                 "month": 9,
+>                 "day": 12
 >             }
 >         },
 >         "resources": {
@@ -143,18 +159,34 @@ All routes support gzip and deflate compression.
 >     "matches": 1,
 >     "url": "https://genius.com/Artist-1-song-name-lyrics",
 >     "path": "/Artist-1-song-name-lyrics",
+>     "language": "en",
 >     "meta": {
 >         "title": "Song Name",
 >         "fullTitle": "Song Name by Artist 1 (ft. Artist 2)",
 >         "artists": "Artist 1 (ft. Artist 2)",
 >         "primaryArtist": {
 >             "name": "Artist 1",
->             "url": "https://genius.com/artists/Artist-1"
+>             "url": "https://genius.com/artists/Artist-1",
+>             "headerImage": "https://images.genius.com/...",
+>             "image": "https://images.genius.com/..."
+>         },
+>         "featuredArtists": [
+>             {
+>                 "name": "Featured Artist 1",
+>                 "url": "https://genius.com/artists/Featured-Artist-1",
+>                 "headerImage": "https://images.genius.com/...",
+>                 "image": "https://images.genius.com/..."
+>             }
+>         ],
+>         "releaseDate": {
+>             "year": 2018,
+>             "month": 9,
+>             "day": 12
 >         }
 >     },
 >     "resources": {
->         "thumbnail": "https://images.genius.com/123456789abcdef.300x300x1.png",
->         "image": "https://images.genius.com/123456789abcdef.1000x1000x1.png"
+>         "thumbnail": "https://images.genius.com/8485557225af0345d2c550af8bae731b.300x300x1.png",
+>         "image": "https://images.genius.com/13d7b13ef827a9f007a5d24c115b9ebb.1000x1000x1.png"
 >     },
 >     "lyricsState": "complete",
 >     "id": 42069,

+ 9 - 1
changelog.md

@@ -1,10 +1,18 @@
 ## Version History:
-- **[1.0.0](#v100)**
+- [1.1.0](#v110)
+- [1.0.0](#v100)
 - [0.2.0](#v020)
 - [0.1.0](#v010)
 
 <br><br>
 
+### v1.1.0
+- Migrated code to TypeScript
+- Added new metadata:
+    - release date
+
+<br>
+
 ### v1.0.0
 - Added `?artist` and `?song` parameters as an alternative to `?q` for getting better search results through fuzzy filtering ([#4](https://github.com/Sv443/geniURL/issues/4))
 - Added `matches` property that's set to the number of results (`0` if none were found, or `null` on error)

Diferenças do arquivo suprimidas por serem muito extensas
+ 748 - 3
package-lock.json


+ 12 - 4
package.json

@@ -1,10 +1,11 @@
 {
   "name": "geniurl",
-  "version": "1.0.0",
+  "version": "1.1.0",
   "description": "Simple JSON and XML REST API to search for song metadata and the lyrics URL on genius.com",
-  "main": "src/index.js",
+  "main": "src/index.ts",
   "scripts": {
-    "test": "echo \"Error: no test specified\" && exit 1"
+    "start": "tsc && node --enable-source-maps out/src/index.js",
+    "watch": "nodemon -e \"ts,d.ts\" -x \"npm start\""
   },
   "repository": {
     "type": "git",
@@ -40,7 +41,14 @@
     "tcp-port-used": "^1.0.2"
   },
   "devDependencies": {
+    "@types/compression": "^1.7.2",
+    "@types/cors": "^2.8.12",
+    "@types/express": "^4.17.14",
+    "@types/node": "^18.11.3",
+    "@types/tcp-port-used": "^1.0.1",
     "dotenv": "^16.0.0",
-    "eslint": "^8.9.0"
+    "eslint": "^8.9.0",
+    "nodemon": "^2.0.20",
+    "tslib": "^2.4.0"
   }
 }

+ 0 - 17
src/error.js

@@ -1,17 +0,0 @@
-const k = require("kleur");
-
-/**
- * @param {string} msg Error message
- * @param {Error} [err] Error instance
- * @param {boolean} [fatal=false] Exits with code 1 if set to true
- */
-function error(msg, err, fatal = false)
-{
-    console.error("\n");
-    console.error(k.red(msg));
-    err && console.error(err);
-
-    fatal && process.exit(1);
-}
-
-module.exports = error;

+ 16 - 0
src/error.ts

@@ -0,0 +1,16 @@
+const k = require("kleur");
+
+/**
+ * Handles an error
+ * @param msg Short error message
+ * @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);
+
+    fatal && process.exit(1);
+}

+ 4 - 4
src/index.js → src/index.ts

@@ -1,9 +1,9 @@
-const dotenv = require("dotenv");
+import dotenv from "dotenv";
 
 dotenv.config();
 
-const server = require("./server");
-const error = require("./error");
+import * as server from "./server";
+import { error } from "./error";
 
 
 async function init()
@@ -18,7 +18,7 @@ async function init()
     }
     catch(err)
     {
-        error(`Error while ${stage}`, err, true);
+        error(`Error while ${stage}`, err instanceof Error ? err : undefined, true);
     }
 }
 

+ 56 - 51
src/server.js → src/server.ts

@@ -1,20 +1,17 @@
-const compression = require("compression");
-const express = require("express");
-const { check: portUsed } = require("tcp-port-used");
-const helmet = require("helmet");
-const { RateLimiterMemory } = require("rate-limiter-flexible");
-const k = require("kleur");
-const cors = require("cors");
-const jsonToXml = require("js2xmlparser");
-
-const packageJson = require("../package.json");
-const error = require("./error");
-const { getMeta } = require("./songMeta");
-
-/** @typedef {import("svcorelib").JSONCompatible} JSONCompatible */
-/** @typedef {import("express").Response} Response */
-/** @typedef {import("./types").ResponseType} ResponseType */
-/** @typedef {import("./types").ResponseFormat} ResponseFormat */
+import compression from "compression";
+import express, { ErrorRequestHandler, NextFunction, Request, Response } from "express";
+import { check as portUsed } from "tcp-port-used";
+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 { ResponseType } from "./types";
+import { Errors, Stringifiable } from "svcorelib";
 
 const app = express();
 
@@ -29,17 +26,17 @@ const rateLimiter = new RateLimiterMemory({
 });
 
 
-async function init()
+export async function init()
 {
-    const port = parseInt(process.env.HTTP_PORT);
+    const port = parseInt(String(process.env.HTTP_PORT));
 
     if(await portUsed(port))
         return error(`TCP port ${port} is already used`, undefined, true);
 
     // on error
-    app.use((err, req, res, next) => {
+    app.use((err: any, 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);
+            return respond(res, "serverError", `General error in HTTP server: ${err.toString()}`, req?.query?.format ? String(req.query.format) : undefined);
         else
             return next();
     });
@@ -49,17 +46,19 @@ async function init()
 
         // rate limiting
         app.use(async (req, res, next) => {
-            try
-            {
-                await rateLimiter.consume(req.ip);
-            }
-            catch(rlRejected)
-            {
-                res.set("Retry-After", String(rlRejected?.msBeforeNext ? Math.round(rlRejected.msBeforeNext / 1000) : 1));
-                return respond(res, 429, { message: "You are being rate limited" }, req?.query?.format);
-            }
-
-            return next();
+            const fmt = req?.query?.format ? String(req.query.format) : undefined;
+
+            res.setHeader("API-Info", `geniURL v${packageJson.version} (${packageJson.homepage})`);
+
+            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();
@@ -78,16 +77,23 @@ function registerEndpoints()
             res.redirect(packageJson.homepage);
         });
 
-        const hasArg = (val) => typeof val === "string" && val.length > 0;
+        const hasArg = (val: unknown) => typeof val === "string" && val.length > 0;
 
         app.get("/search", async (req, res) => {
             try
             {
-                const { q, artist, song, format } = req.query;
+                const { q, artist, song, format: fmt } = req.query;
+
+                const format = fmt ? String(fmt) : "json";
 
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 {
-                    const meta = await getMeta({ q, artist, song });
+                    const meta = await getMeta(q ? {
+                        q: String(q),
+                    } : {
+                        artist: String(artist),
+                        song: String(song),
+                    });
 
                     if(!meta || meta.all.length < 1)
                         return respond(res, "clientError", "Found no results matching your search query", format, 0);
@@ -98,22 +104,29 @@ function registerEndpoints()
                     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);
+                    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, "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 } = req.query;
+                const { q, artist, song, format: fmt } = req.query;
+
+                const format = fmt ? String(fmt) : "json";
 
                 if(hasArg(q) || (hasArg(artist) && hasArg(song)))
                 {
-                    const meta = await getMeta({ q, artist, song });
+                    const meta = await getMeta(q ? {
+                        q: String(q),
+                    } : {
+                        artist: String(artist),
+                        song: String(song),
+                    });
 
                     if(!meta || !meta.top)
                         return respond(res, "clientError", "Found no results matching your search query", format, 0);
@@ -121,7 +134,7 @@ function registerEndpoints()
                     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);
+                    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)
             {
@@ -131,17 +144,11 @@ function registerEndpoints()
     }
     catch(err)
     {
-        error("Error while registering endpoints", err, true);
+        error("Error while registering endpoints", err instanceof Error ? err : undefined, true);
     }
 }
 
-/**
- * @param {Response} res
- * @param {ResponseType|number} type Specifies the type of response and thus a predefined status code - overload: set to number for custom status code
- * @param {JSONCompatible} data JSON object for "success", else an error message string
- * @param {ResponseFormat} [format]
- */
-function respond(res, type, data, format, matchesAmt)
+function respond(res: Response, type: ResponseType | number, data: Stringifiable | Record<string, unknown>, format = "json", matchesAmt = 0)
 {
     let statusCode = 500;
     let error = true;
@@ -158,7 +165,7 @@ function respond(res, type, data, format, matchesAmt)
     {
         case "success":
             error = false;
-            matches = matchesAmt ?? 0;
+            matches = matchesAmt;
             statusCode = 200;
             resData = { ...data };
             break;
@@ -199,5 +206,3 @@ function respond(res, type, data, format, matchesAmt)
     res.setHeader("Content-Type", mimeType);
     res.status(statusCode).send(finalData);
 }
-
-module.exports = { init };

+ 0 - 113
src/songMeta.js

@@ -1,113 +0,0 @@
-const axios = require("axios");
-const Fuse = require("fuse.js");
-const { randomUUID } = require("crypto");
-const { reserialize } = require("svcorelib");
-
-/** @typedef {import("./types").SongMeta} SongMeta */
-
-/**
- * Returns meta information about the top results of a search using the genius API
- * @param {Record<"q"|"artist"|"song", string|undefined>} search
- * @returns {Promise<{ top: SongMeta, all: SongMeta[] } | null>} Resolves null if no results are found
- */
-async function getMeta({ q, artist, song })
-{
-    const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
-
-    const query = q ? q : `${artist} ${song}`;
-
-    const { data: { response }, status } = await axios.get(`https://api.genius.com/search?q=${encodeURIComponent(query)}`, {
-        headers: { "Authorization": `Bearer ${accessToken}` },
-    });
-
-    if(status >= 200 && status < 300 && Array.isArray(response?.hits))
-    {
-        if(response.hits.length === 0)
-            return null;
-
-        let hits = response.hits
-            .filter(h => h.type === "song")
-            .map(({ result }) => ({
-                url: result.url,
-                path: result.path,
-                meta: {
-                    title: normalizeString(result.title),
-                    fullTitle: normalizeString(result.full_title),
-                    artists: normalizeString(result.artist_names),
-                    primaryArtist: {
-                        name: normalizeString(result.primary_artist.name),
-                        url: result.primary_artist.url,
-                    },
-                },
-                resources: {
-                    thumbnail: result.song_art_image_thumbnail_url,
-                    image: result.song_art_image_url,
-                },
-                lyricsState: result.lyrics_state,
-                id: result.id,
-            }));
-
-        if(artist && song)
-        {
-            /** @type {Record<string, number>} */
-            const scoreMap = {};
-
-            hits = hits.map(h => {
-                h.uuid = randomUUID();
-                return h;
-            });
-
-            const fuseOpts = {
-                ignoreLocation: true,
-                includeScore: true,
-                threshold: 0.5,
-            };
-
-            const titleFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.title" ] });
-            const artistFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.primaryArtist.name" ] });
-
-            /** @param {({ item: { uuid: string }, score: number })[]} searchRes */
-            const addScores = (searchRes) => searchRes.forEach(({ item, score }) => {
-                if(!scoreMap[item.uuid])
-                    scoreMap[item.uuid] = score;
-                else
-                    scoreMap[item.uuid] += score;
-            });
-
-            addScores(titleFuse.search(song));
-            addScores(artistFuse.search(artist));
-
-            const bestMatches = Object.entries(scoreMap)
-                .sort(([, valA], [, valB]) => valA > valB)
-                .map(e => e[0]);
-
-            const oldHits = reserialize(hits);
-
-            hits = bestMatches
-                .map(uuid => oldHits.find(h => h.uuid === uuid))
-                .map(hit => {
-                    delete hit.uuid;
-                    return hit;
-                });
-        }
-
-        return {
-            top: hits[0],
-            all: hits.slice(0, 10),
-        };
-    }
-
-    return null;
-}
-
-/**
- * Removes invisible characters and control characters from a string
- * @param {string} str
- * @returns {string}
- */
-function normalizeString(str)
-{
-    return str.replace(/[\u0000-\u001F\u007F-\u009F\u200B]/g, "").replace(/\u00A0/g, " ");
-}
-
-module.exports = { getMeta };

+ 132 - 0
src/songMeta.ts

@@ -0,0 +1,132 @@
+import axios from "axios";
+import Fuse from "fuse.js";
+import { randomUUID } from "crypto";
+import { JSONCompatible, reserialize } from "svcorelib";
+import { ApiSearchResult, SongMeta } from "./types";
+
+type MetaSearchHit = SongMeta & { uuid?: string; };
+
+/**
+ * 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 }: Partial<Record<"q" | "artist" | "song", string>>): Promise<{ top: SongMeta, all: SongMeta[] } | null>
+{
+    const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
+
+    const query = q ? q : `${artist} ${song}`;
+
+    const { data: { response }, status } = await axios.get<ApiSearchResult>(`https://api.genius.com/search?q=${encodeURIComponent(query)}`, {
+        headers: { "Authorization": `Bearer ${accessToken}` },
+    });
+
+    if(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: 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 ?? 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,
+            }));
+
+        if(artist && song)
+        {
+            const scoreMap: Record<string, number> = {};
+
+            hits = hits.map(h => {
+                h.uuid = randomUUID();
+                return h;
+            }) as (SongMeta & { uuid: string })[];
+
+            const fuseOpts: Fuse.IFuseOptions<MetaSearchHit> = {
+                ignoreLocation: true,
+                includeScore: true,
+                threshold: 0.6,
+            };
+
+            const titleFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.title" ] });
+            const artistFuse = new Fuse(hits, { ...fuseOpts, keys: [ "meta.primaryArtist.name" ] });
+
+            /** @param {({ item: { uuid: string }, score: number })[]} searchRes */
+            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;
+                });
+
+            addScores(titleFuse.search(song));
+            addScores(artistFuse.search(artist));
+
+            const bestMatches = Object.entries(scoreMap)
+                .sort(([, valA], [, valB]) => valA > valB ? 1 : -1)
+                .map(e => e[0]);
+
+            const oldHits = reserialize(hits as unknown as JSONCompatible) as unknown as MetaSearchHit[];
+
+            hits = bestMatches
+                .map(uuid => oldHits.find(h => h.uuid === uuid))
+                .map(hit => {
+                    if(hit)
+                    {
+                        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, "").replace(/\u00A0/g, " ");
+}

+ 71 - 8
src/types.d.ts

@@ -1,21 +1,32 @@
 //#SECTION meta
 
+interface Artist {
+    name: string | null;
+    url: string | null;
+    image: string | null;
+    headerImage: string | null;
+}
+
 export interface SongMeta {
     url: string;
     path: string;
+    language: string | null;
     meta: {
         title: string;
         fullTitle: string;
         artists: string;
-        primaryArtist: {
-            name: string;
-            url: string;
-        },
-    },
+        releaseDate: {
+            year: number | null;
+            month: number | null;
+            day: number | null;
+        };
+        primaryArtist: Artist | null;
+        featuredArtists: Artist[];
+    };
     resources: {
-        thumbnail: string;
-        image: string;
-    },
+        thumbnail: string | null;
+        image: string | null;
+    };
     lyricsState: string;
     id: number;
 }
@@ -25,3 +36,55 @@ export interface SongMeta {
 export type ResponseType = "serverError" | "clientError" | "success";
 
 export type ResponseFormat = "json" | "xml";
+
+//#SECTION API
+
+export type ApiSearchResult = {
+    response: {
+        hits: SearchHit[];
+    };
+};
+
+/** 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;
+        release_date_components: {
+            year: number;
+            month: number;
+            day: number;
+        };
+        song_art_image_thumbnail_url: string;
+        song_art_image_url: 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;
+        };
+    };
+};

+ 22 - 0
tsconfig.json

@@ -0,0 +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,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noUncheckedIndexedAccess": true,
+  }
+}

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff