浏览代码

fixed #4, add matches prop

Sv443 2 年之前
父节点
当前提交
19bb92cd25
共有 6 个文件被更改,包括 220 次插入78 次删除
  1. 52 10
      README.md
  2. 12 5
      changelog.md
  3. 50 35
      package-lock.json
  4. 3 2
      package.json
  5. 46 20
      src/server.js
  6. 57 6
      src/songMeta.js

+ 52 - 10
README.md

@@ -13,8 +13,8 @@ I host a public instance on this URL:
 https://api.sv443.net/geniurl/
 ```
 
-Note that this instance is rate limited to 8 requests in 10 seconds.  
-If you want to host your own and increase the values, look at the top of `src/server.js`
+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>
 
 <br><br>
 
@@ -31,13 +31,19 @@ All routes support gzip and deflate compression.
 >
 > <br>
 >
-> **Parameters:**  
-> `?q=search%20query` (required)  
+> **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 whitespace).  
 > Sometimes the song name alone might be enough but the results vary greatly.  
+> Using this parameter instead of `?artist` and `?song` will not modify the search results and so you will rarely get blatantly wrong top matches.  
 > Make sure the search query is [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
 >   
-> `?format=json/xml` (optional)  
+> `?artist=name` and `?song=name`  
+> Instead of `?q`, you can use `?artist` and `?song` to tell geniURL to preemptively filter the search results.  
+> This is done using a fuzzy search to greatly increase the chances the correct search result will be at the top.  
+> Make sure these parameters are [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
+>   
+> `?format=json/xml`  
 > Use this parameter to change the response format from the default (`json`) to `xml`  
 > The structure of the data closely resembles that of the shown JSON data.
 >
@@ -47,6 +53,7 @@ All routes support gzip and deflate compression.
 > ```json
 > {
 >     "error": false,
+>     "matches": 10,
 >     "top": {
 >         "url": "https://genius.com/Artist-1-song-name-lyrics",
 >         "path": "/Artist-1-song-name-lyrics",
@@ -81,11 +88,25 @@ All routes support gzip and deflate compression.
 > ```json
 > {
 >     "error": true,
+>     "matches": null,
 >     "message": "Something went wrong",
 >     "timestamp": 1234567890123
 > }
 > ```
 >
+> </details>
+> <br>
+> <details><summary>Response when no results found (click to view)</summary>
+>
+> ```json
+> {
+>     "error": false,
+>     "matches": 0,
+>     "message": "Found no results matching your search query",
+>     "timestamp": 1234567890123
+> }
+> ```
+>
 > </details><br>
 
 <br><br>
@@ -97,13 +118,19 @@ All routes support gzip and deflate compression.
 >
 > <br>
 >
-> **Parameters:**  
-> `?q=search%20query` (required)  
+> **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 whitespace).  
 > Sometimes the song name alone might be enough but the results vary greatly.  
+> Using this parameter instead of `?artist` and `?song` will not modify the search result and so you will rarely get a blatantly wrong top match.  
 > Make sure the search query is [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
 >   
-> `?format=json/xml` (optional)  
+> `?artist=name` and `?song=name`  
+> Instead of `?q`, you can use `?artist` and `?song` to tell geniURL to preemptively filter the search results.  
+> This is done using a fuzzy search to greatly increase the chances the correct search result will be returned.  
+> Make sure these parameters are [percent/URL-encoded.](https://en.wikipedia.org/wiki/Percent-encoding)  
+>   
+> `?format=json/xml`  
 > Use this parameter to change the response format from the default (`json`) to `xml`  
 > The structure of the data closely resembles that of the shown JSON data.
 >
@@ -113,6 +140,7 @@ All routes support gzip and deflate compression.
 > ```json
 > {
 >     "error": false,
+>     "matches": 1,
 >     "url": "https://genius.com/Artist-1-song-name-lyrics",
 >     "path": "/Artist-1-song-name-lyrics",
 >     "meta": {
@@ -141,18 +169,32 @@ All routes support gzip and deflate compression.
 > ```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,
+>     "message": "Found no results matching your search query",
+>     "timestamp": 1234567890123
+> }
+> ```
+>
 > </details><br>
 
 <br><br>
 
 <div align="center" style="text-align:center;">
 
-Made with low effort but still lots of ❤️ by [Sv443](https://sv443.net/)  
-Licensed under the [MIT license](./LICENSE.txt#readme)
+Made with ❤️ by [Sv443](https://sv443.net/)  
+If you like geniURL, please consider [supporting the development](https://github.com/sponsors/Sv443)
 
 </div>

+ 12 - 5
changelog.md

@@ -1,20 +1,27 @@
 ## Version History:
-- **[0.2.0](#v020)**
+- **[1.0.0](#v100)**
+- [0.2.0](#v020)
 - [0.1.0](#v010)
 
 <br><br>
 
-## v0.2.0
+### 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)
+
+<br>
+
+### v0.2.0
 - Added XML format
 - API now filters out invisible characters ([#1](https://github.com/Sv443/geniURL/issues/1))
 - Improvements to reliability
 
-<br><br>
+<br>
 
-## v0.1.0
+### v0.1.0
 - Added endpoints
     - `/search` to search for the top result and the 10 best matches
     - `/search/top` to only search for the top result
 - Added gzip and brotli encoding
 
-<br><br>
+<br>

+ 50 - 35
package-lock.json

@@ -1,23 +1,24 @@
 {
   "name": "geniurl",
-  "version": "0.1.0",
+  "version": "1.0.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "geniurl",
-      "version": "0.1.0",
+      "version": "1.0.0",
       "license": "MIT",
       "dependencies": {
         "axios": "^0.26.0",
         "compression": "^1.7.4",
         "cors": "^2.8.5",
         "express": "^4.17.3",
+        "fuse.js": "^6.6.2",
         "helmet": "^5.0.2",
         "js2xmlparser": "^4.0.2",
         "kleur": "^4.1.4",
         "rate-limiter-flexible": "^2.3.6",
-        "svcorelib": "^1.14.2",
+        "svcorelib": "^1.15.0",
         "tcp-port-used": "^1.0.2"
       },
       "devDependencies": {
@@ -229,7 +230,7 @@
       "version": "9.0.0",
       "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz",
       "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==",
-      "peer": true,
+      "optional": true,
       "engines": {
         "node": "*"
       }
@@ -396,7 +397,7 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
       "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
-      "peer": true
+      "optional": true
     },
     "node_modules/cors": {
       "version": "2.8.5",
@@ -847,6 +848,14 @@
       "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
       "dev": true
     },
+    "node_modules/fuse.js": {
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
+      "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA==",
+      "engines": {
+        "node": ">=10"
+      }
+    },
     "node_modules/glob": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
@@ -1050,7 +1059,7 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
       "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
-      "peer": true
+      "optional": true
     },
     "node_modules/isexe": {
       "version": "2.0.0",
@@ -1204,7 +1213,7 @@
       "version": "2.18.1",
       "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz",
       "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==",
-      "peer": true,
+      "optional": true,
       "dependencies": {
         "bignumber.js": "9.0.0",
         "readable-stream": "2.3.7",
@@ -1219,7 +1228,7 @@
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-      "peer": true
+      "optional": true
     },
     "node_modules/natural-compare": {
       "version": "1.4.0",
@@ -1344,7 +1353,7 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
       "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
-      "peer": true
+      "optional": true
     },
     "node_modules/proxy-addr": {
       "version": "2.0.7",
@@ -1409,7 +1418,7 @@
       "version": "2.3.7",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
       "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
-      "peer": true,
+      "optional": true,
       "dependencies": {
         "core-util-is": "~1.0.0",
         "inherits": "~2.0.3",
@@ -1424,7 +1433,7 @@
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-      "peer": true
+      "optional": true
     },
     "node_modules/regexpp": {
       "version": "3.2.0",
@@ -1558,7 +1567,7 @@
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz",
       "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=",
-      "peer": true,
+      "optional": true,
       "engines": {
         "node": ">= 0.6"
       }
@@ -1575,7 +1584,7 @@
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
       "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
-      "peer": true,
+      "optional": true,
       "dependencies": {
         "safe-buffer": "~5.1.0"
       }
@@ -1584,7 +1593,7 @@
       "version": "5.1.2",
       "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
       "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-      "peer": true
+      "optional": true
     },
     "node_modules/strip-ansi": {
       "version": "6.0.1",
@@ -1623,16 +1632,16 @@
       }
     },
     "node_modules/svcorelib": {
-      "version": "1.14.2",
-      "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.14.2.tgz",
-      "integrity": "sha512-3+D6qIQKmqqCg9oddDUeDtg201TV/qMYBRJkQWnpPBMfZYFhFT3pyECgOOWXvqgRB+AOy1pCoafku5kmbMBL4A==",
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.15.0.tgz",
+      "integrity": "sha512-M8zllkdQbS8xTMTa2UKIa52Bbso5mnV2ITChmW51AXvDTJV1Kw3BM8O3Z9G2xN+A0keOzSi9nokCcOs4egZ9Eg==",
       "dependencies": {
         "deep-diff": "^1.0.2",
         "fs-extra": "^9.0.1",
         "keypress": "^0.2.1",
         "minimatch": "^3.0.4"
       },
-      "peerDependencies": {
+      "optionalDependencies": {
         "mysql": "^2.18.1"
       }
     },
@@ -1759,7 +1768,7 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
       "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
-      "peer": true
+      "optional": true
     },
     "node_modules/utils-merge": {
       "version": "1.0.1",
@@ -1976,7 +1985,7 @@
       "version": "9.0.0",
       "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.0.0.tgz",
       "integrity": "sha512-t/OYhhJ2SD+YGBQcjY8GzzDHEk9f3nerxjtfa6tlMXfe7frs/WozhvCNoGvpM0P3bNf3Gq5ZRMlGr5f3r4/N8A==",
-      "peer": true
+      "optional": true
     },
     "body-parser": {
       "version": "1.19.2",
@@ -2106,7 +2115,7 @@
       "version": "1.0.3",
       "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
       "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
-      "peer": true
+      "optional": true
     },
     "cors": {
       "version": "2.8.5",
@@ -2452,6 +2461,11 @@
       "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=",
       "dev": true
     },
+    "fuse.js": {
+      "version": "6.6.2",
+      "resolved": "https://registry.npmjs.org/fuse.js/-/fuse.js-6.6.2.tgz",
+      "integrity": "sha512-cJaJkxCCxC8qIIcPBF9yGxY0W/tVZS3uEISDxhYIdtk8OL93pe+6Zj7LjCqVV4dzbqcriOZ+kQ/NE4RXZHsIGA=="
+    },
     "glob": {
       "version": "7.2.0",
       "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.0.tgz",
@@ -2601,7 +2615,7 @@
       "version": "1.0.0",
       "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
       "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
-      "peer": true
+      "optional": true
     },
     "isexe": {
       "version": "2.0.0",
@@ -2723,7 +2737,7 @@
       "version": "2.18.1",
       "resolved": "https://registry.npmjs.org/mysql/-/mysql-2.18.1.tgz",
       "integrity": "sha512-Bca+gk2YWmqp2Uf6k5NFEurwY/0td0cpebAucFpY/3jhrwrVGuxU2uQFCHjU19SJfje0yQvi+rVWdq78hR5lig==",
-      "peer": true,
+      "optional": true,
       "requires": {
         "bignumber.js": "9.0.0",
         "readable-stream": "2.3.7",
@@ -2735,7 +2749,7 @@
           "version": "5.1.2",
           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
           "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-          "peer": true
+          "optional": true
         }
       }
     },
@@ -2832,7 +2846,7 @@
       "version": "2.0.1",
       "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
       "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
-      "peer": true
+      "optional": true
     },
     "proxy-addr": {
       "version": "2.0.7",
@@ -2879,7 +2893,7 @@
       "version": "2.3.7",
       "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
       "integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
-      "peer": true,
+      "optional": true,
       "requires": {
         "core-util-is": "~1.0.0",
         "inherits": "~2.0.3",
@@ -2894,7 +2908,7 @@
           "version": "5.1.2",
           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
           "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-          "peer": true
+          "optional": true
         }
       }
     },
@@ -2991,7 +3005,7 @@
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.1.tgz",
       "integrity": "sha1-R1OT/56RR5rqYtyvDKPRSYOn+0A=",
-      "peer": true
+      "optional": true
     },
     "statuses": {
       "version": "1.5.0",
@@ -3002,7 +3016,7 @@
       "version": "1.1.1",
       "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
       "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
-      "peer": true,
+      "optional": true,
       "requires": {
         "safe-buffer": "~5.1.0"
       },
@@ -3011,7 +3025,7 @@
           "version": "5.1.2",
           "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
           "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
-          "peer": true
+          "optional": true
         }
       }
     },
@@ -3040,14 +3054,15 @@
       }
     },
     "svcorelib": {
-      "version": "1.14.2",
-      "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.14.2.tgz",
-      "integrity": "sha512-3+D6qIQKmqqCg9oddDUeDtg201TV/qMYBRJkQWnpPBMfZYFhFT3pyECgOOWXvqgRB+AOy1pCoafku5kmbMBL4A==",
+      "version": "1.15.0",
+      "resolved": "https://registry.npmjs.org/svcorelib/-/svcorelib-1.15.0.tgz",
+      "integrity": "sha512-M8zllkdQbS8xTMTa2UKIa52Bbso5mnV2ITChmW51AXvDTJV1Kw3BM8O3Z9G2xN+A0keOzSi9nokCcOs4egZ9Eg==",
       "requires": {
         "deep-diff": "^1.0.2",
         "fs-extra": "^9.0.1",
         "keypress": "^0.2.1",
-        "minimatch": "^3.0.4"
+        "minimatch": "^3.0.4",
+        "mysql": "^2.18.1"
       },
       "dependencies": {
         "fs-extra": {
@@ -3145,7 +3160,7 @@
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
       "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
-      "peer": true
+      "optional": true
     },
     "utils-merge": {
       "version": "1.0.1",

+ 3 - 2
package.json

@@ -1,6 +1,6 @@
 {
   "name": "geniurl",
-  "version": "0.2.0",
+  "version": "1.0.0",
   "description": "Simple JSON and XML REST API to search for song metadata and the lyrics URL on genius.com",
   "main": "src/index.js",
   "scripts": {
@@ -31,11 +31,12 @@
     "compression": "^1.7.4",
     "cors": "^2.8.5",
     "express": "^4.17.3",
+    "fuse.js": "^6.6.2",
     "helmet": "^5.0.2",
     "js2xmlparser": "^4.0.2",
     "kleur": "^4.1.4",
     "rate-limiter-flexible": "^2.3.6",
-    "svcorelib": "^1.14.2",
+    "svcorelib": "^1.15.0",
     "tcp-port-used": "^1.0.2"
   },
   "devDependencies": {

+ 46 - 20
src/server.js

@@ -24,7 +24,7 @@ app.use(express.json());
 app.use(compression());
 
 const rateLimiter = new RateLimiterMemory({
-    points: 8,
+    points: 5,
     duration: 10,
 });
 
@@ -78,35 +78,55 @@ function registerEndpoints()
             res.redirect(packageJson.homepage);
         });
 
-        app.get("/search", async (req, res) => {
-            const { q, format } = req.query;
+        const hasArg = (val) => typeof val === "string" && val.length > 0;
 
-            if(typeof q !== "string" || q.length === 0)
-                return respond(res, "clientError", "No query parameter (?q=...) provided or it is invalid", req?.query?.format);
+        app.get("/search", async (req, res) => {
+            try
+            {
+                const { q, artist, song, format } = req.query;
 
-            const meta = await getMeta(q);
+                if(hasArg(q) || (hasArg(artist) && hasArg(song)))
+                {
+                    const meta = await getMeta({ q, artist, song });
 
-            if(!meta)
-                return respond(res, "clientError", "Found no results matching your search query", format);
+                    if(!meta || meta.all.length < 1)
+                        return respond(res, "clientError", "Found no results matching your search query", format, 0);
 
-            // js2xmlparser needs special treatment when using arrays to produce a decent XML structure
-            const response = format !== "xml" ? meta : { ...meta, all: { "result": meta.all } };
+                    // js2xmlparser needs special treatment when using arrays to produce a decent XML structure
+                    const response = format !== "xml" ? meta : { ...meta, all: { "result": meta.all } };
 
-            return respond(res, "success", response, format);
+                    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);
+            }
+            catch(err)
+            {
+                return respond(res, "serverError", `Encountered an internal server error${err instanceof Error ? err.message : ""}`, "json");
+            }
         });
 
         app.get("/search/top", async (req, res) => {
-            const { q, format } = req.query;
-
-            if(typeof q !== "string" || q.length === 0)
-                return respond(res, "clientError", "No query parameter (?q=...) provided or it is invalid", req?.query?.format);
+            try
+            {
+                const { q, artist, song, format } = req.query;
 
-            const meta = await getMeta(q);
+                if(hasArg(q) || (hasArg(artist) && hasArg(song)))
+                {
+                    const meta = await getMeta({ q, artist, song });
 
-            if(!meta)
-                return respond(res, "clientError", "Found no results matching your search query", format);
+                    if(!meta || !meta.top)
+                        return respond(res, "clientError", "Found no results matching your search query", format, 0);
 
-            return respond(res, "success", meta.top, format);
+                    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);
+            }
+            catch(err)
+            {
+                return respond(res, "serverError", `Encountered an internal server error${err instanceof Error ? err.message : ""}`, "json");
+            }
         });
     }
     catch(err)
@@ -121,10 +141,11 @@ function registerEndpoints()
  * @param {JSONCompatible} data JSON object for "success", else an error message string
  * @param {ResponseFormat} [format]
  */
-function respond(res, type, data, format)
+function respond(res, type, data, format, matchesAmt)
 {
     let statusCode = 500;
     let error = true;
+    let matches = null;
 
     let resData = {};
 
@@ -137,16 +158,19 @@ function respond(res, type, data, format)
     {
         case "success":
             error = false;
+            matches = matchesAmt ?? 0;
             statusCode = 200;
             resData = { ...data };
             break;
         case "clientError":
             error = true;
+            matches = matchesAmt ?? null;
             statusCode = 400;
             resData = { message: data };
             break;
         case "serverError":
             error = true;
+            matches = matchesAmt ?? null;
             statusCode = 500;
             resData = { message: data };
             break;
@@ -154,6 +178,7 @@ function respond(res, type, data, format)
             if(typeof type === "number")
             {
                 error = false;
+                matches = matchesAmt ?? 0;
                 statusCode = type;
                 resData = { ...data };
             }
@@ -164,6 +189,7 @@ function respond(res, type, data, format)
 
     resData = {
         error,
+        matches,
         ...resData,
         timestamp: Date.now(),
     };

+ 57 - 6
src/songMeta.js

@@ -1,26 +1,31 @@
-const { default: axios } = require("axios");
+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 {string} search
+ * @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(search)
+async function getMeta({ q, artist, song })
 {
     const accessToken = process.env.GENIUS_ACCESS_TOKEN ?? "ERR_NO_ENV";
 
-    const { data: { response } } = await axios.get(`https://api.genius.com/search?q=${encodeURIComponent(search)}`, {
+    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(Array.isArray(response?.hits))
+    if(status >= 200 && status < 300 && Array.isArray(response?.hits))
     {
         if(response.hits.length === 0)
             return null;
 
-        const hits = response.hits
+        let hits = response.hits
             .filter(h => h.type === "song")
             .map(({ result }) => ({
                 url: result.url,
@@ -42,11 +47,57 @@ async function getMeta(search)
                 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;
 }
 
 /**