Database overhall with knex.js, some things untested

This commit is contained in:
Eclypsed
2024-06-21 03:35:00 -04:00
parent ca80a6476f
commit 28c825b04b
40 changed files with 941 additions and 901 deletions

608
package-lock.json generated
View File

@@ -9,14 +9,15 @@
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.10",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",
"bcrypt-ts": "^5.0.1", "bcrypt-ts": "^5.0.1",
"better-sqlite3": "^9.3.0", "better-sqlite3": "^9.3.0",
"fast-average-color": "^9.4.0", "fast-average-color": "^9.4.0",
"googleapis": "^133.0.0", "googleapis": "^133.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"musicbrainz-api": "^0.15.0" "knex": "^3.1.0",
"zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-auto": "^3.0.0", "@sveltejs/adapter-auto": "^3.0.0",
@@ -748,17 +749,6 @@
"win32" "win32"
] ]
}, },
"node_modules/@sindresorhus/is": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-6.3.0.tgz",
"integrity": "sha512-bOSPck7aIJjASXIg1qvXSIjXhVBpIEKdl2Wxg4pVqoTRPL8wWExKBrnGIh6CEnhkFQHfc36k7APhO3uXV4g5xg==",
"engines": {
"node": ">=16"
},
"funding": {
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@sveltejs/adapter-auto": { "node_modules/@sveltejs/adapter-auto": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz",
@@ -842,30 +832,14 @@
"vite": "^5.0.0" "vite": "^5.0.0"
} }
}, },
"node_modules/@szmarczak/http-timer": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz",
"integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==",
"dependencies": {
"defer-to-connect": "^2.0.1"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/@types/better-sqlite3": { "node_modules/@types/better-sqlite3": {
"version": "7.6.8", "version": "7.6.10",
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.8.tgz", "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.10.tgz",
"integrity": "sha512-ASndM4rdGrzk7iXXqyNC4fbwt4UEjpK0i3j4q4FyeQrLAthfB6s7EF135ZJE0qQxtKIMFwmyT6x0switET7uIw==", "integrity": "sha512-TZBjD+yOsyrUJGmcUj6OS3JADk3+UZcNv3NOBqGkM09bZdi28fNZw8ODqbMOLfKCu7RYCO62/ldq1iHbzxqoPw==",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"
} }
}, },
"node_modules/@types/caseless": {
"version": "0.12.5",
"resolved": "https://registry.npmjs.org/@types/caseless/-/caseless-0.12.5.tgz",
"integrity": "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="
},
"node_modules/@types/cookie": { "node_modules/@types/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
@@ -878,11 +852,6 @@
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==", "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
"dev": true "dev": true
}, },
"node_modules/@types/http-cache-semantics": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz",
"integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA=="
},
"node_modules/@types/jsonwebtoken": { "node_modules/@types/jsonwebtoken": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz",
@@ -905,35 +874,6 @@
"integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==", "integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
"dev": true "dev": true
}, },
"node_modules/@types/request": {
"version": "2.48.12",
"resolved": "https://registry.npmjs.org/@types/request/-/request-2.48.12.tgz",
"integrity": "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw==",
"dependencies": {
"@types/caseless": "*",
"@types/node": "*",
"@types/tough-cookie": "*",
"form-data": "^2.5.0"
}
},
"node_modules/@types/request-promise-native": {
"version": "1.0.21",
"resolved": "https://registry.npmjs.org/@types/request-promise-native/-/request-promise-native-1.0.21.tgz",
"integrity": "sha512-NJ1M6iqWTEUT+qdP+OmXsRZ6tSdkoBdblHKatIWTVP1HdYpHU3IkfpLPf4MWb0+CC4Nl3TtLpYhDlhjZxytDIA==",
"dependencies": {
"@types/request": "*"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
"integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="
},
"node_modules/@types/uuid": {
"version": "9.0.8",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz",
"integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.11.3", "version": "8.11.3",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
@@ -1015,11 +955,6 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/autoprefixer": { "node_modules/autoprefixer": {
"version": "10.4.17", "version": "10.4.17",
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz",
@@ -1155,12 +1090,12 @@
} }
}, },
"node_modules/braces": { "node_modules/braces": {
"version": "3.0.2", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"fill-range": "^7.0.1" "fill-range": "^7.1.1"
}, },
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1235,58 +1170,6 @@
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="
}, },
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="
},
"node_modules/cacheable-lookup": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz",
"integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==",
"engines": {
"node": ">=14.16"
}
},
"node_modules/cacheable-request": {
"version": "10.2.14",
"resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz",
"integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==",
"dependencies": {
"@types/http-cache-semantics": "^4.0.2",
"get-stream": "^6.0.1",
"http-cache-semantics": "^4.1.1",
"keyv": "^4.5.3",
"mimic-response": "^4.0.0",
"normalize-url": "^8.0.0",
"responselike": "^3.0.0"
},
"engines": {
"node": ">=14.16"
}
},
"node_modules/cacheable-request/node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
"integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/cacheable-request/node_modules/mimic-response": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz",
"integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/call-bind": { "node_modules/call-bind": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
@@ -1324,9 +1207,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001579", "version": "1.0.30001636",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz",
"integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -1343,11 +1226,6 @@
} }
] ]
}, },
"node_modules/caseless": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw=="
},
"node_modules/chokidar": { "node_modules/chokidar": {
"version": "3.5.3", "version": "3.5.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
@@ -1411,16 +1289,10 @@
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true "dev": true
}, },
"node_modules/combined-stream": { "node_modules/colorette": {
"version": "1.0.8", "version": "2.0.19",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ=="
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
}, },
"node_modules/commander": { "node_modules/commander": {
"version": "4.1.1", "version": "4.1.1",
@@ -1532,14 +1404,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/defer-to-connect": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz",
"integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==",
"engines": {
"node": ">=10"
}
},
"node_modules/define-data-property": { "node_modules/define-data-property": {
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
@@ -1556,14 +1420,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": { "node_modules/dequal": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@@ -1709,7 +1565,14 @@
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==",
"dev": true, "engines": {
"node": ">=6"
}
},
"node_modules/esm": {
"version": "3.2.25",
"resolved": "https://registry.npmjs.org/esm/-/esm-3.2.25.tgz",
"integrity": "sha512-U1suiZ2oDVWv4zPO56S0NcR5QriEahGtdN2OR6FiOG4WJvcjBVFB0qI4+eKoWFH483PKGuLuu6V8Z4T5g63UVA==",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
} }
@@ -1781,9 +1644,9 @@
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==" "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
}, },
"node_modules/fill-range": { "node_modules/fill-range": {
"version": "7.0.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -1808,27 +1671,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/form-data": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-2.5.1.tgz",
"integrity": "sha512-m21N3WOmEEURgk6B9GLOE4RuWOFf28Lhh9qGYeNlGq4VDXUlJy2th2slBNU8Gp8EzloYZOibZJ7t5ecIrFSjVA==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.6",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 0.12"
}
},
"node_modules/form-data-encoder": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-4.0.2.tgz",
"integrity": "sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==",
"engines": {
"node": ">= 18"
}
},
"node_modules/fraction.js": { "node_modules/fraction.js": {
"version": "4.3.7", "version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -1919,17 +1761,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/get-stream": { "node_modules/get-package-type": {
"version": "8.0.1", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz", "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"engines": { "engines": {
"node": ">=16" "node": ">=8.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/getopts": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/getopts/-/getopts-2.3.0.tgz",
"integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA=="
},
"node_modules/github-from-package": { "node_modules/github-from-package": {
"version": "0.0.0", "version": "0.0.0",
"resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
@@ -2053,30 +1897,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/got": {
"version": "14.2.1",
"resolved": "https://registry.npmjs.org/got/-/got-14.2.1.tgz",
"integrity": "sha512-KOaPMremmsvx6l9BLC04LYE6ZFW4x7e4HkTe3LwBmtuYYQwpeS4XKqzhubTIkaQ1Nr+eXxeori0zuwupXMovBQ==",
"dependencies": {
"@sindresorhus/is": "^6.1.0",
"@szmarczak/http-timer": "^5.0.1",
"cacheable-lookup": "^7.0.0",
"cacheable-request": "^10.2.14",
"decompress-response": "^6.0.0",
"form-data-encoder": "^4.0.2",
"get-stream": "^8.0.1",
"http2-wrapper": "^2.2.1",
"lowercase-keys": "^3.0.0",
"p-cancelable": "^4.0.1",
"responselike": "^3.0.0"
},
"engines": {
"node": ">=20"
},
"funding": {
"url": "https://github.com/sindresorhus/got?sponsor=1"
}
},
"node_modules/graceful-fs": { "node_modules/graceful-fs": {
"version": "4.2.11", "version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -2158,28 +1978,6 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/http-cache-semantics": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
"integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ=="
},
"node_modules/http-status-codes": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz",
"integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA=="
},
"node_modules/http2-wrapper": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.1.tgz",
"integrity": "sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==",
"dependencies": {
"quick-lru": "^5.1.1",
"resolve-alpn": "^1.2.0"
},
"engines": {
"node": ">=10.19.0"
}
},
"node_modules/https-proxy-agent": { "node_modules/https-proxy-agent": {
"version": "7.0.4", "version": "7.0.4",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz",
@@ -2257,6 +2055,14 @@
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==" "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="
}, },
"node_modules/interpret": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.2.0.tgz",
"integrity": "sha512-Ju0Bz/cEia55xDwUWEa8+olFpCiQoypjnQySseKtmjNrnps3P+xfpUmGr90T7yjlVJmOtybRvPXhKMbHr+fWnw==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-binary-path": { "node_modules/is-binary-path": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -2273,7 +2079,6 @@
"version": "2.13.1", "version": "2.13.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
"integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
"dev": true,
"dependencies": { "dependencies": {
"hasown": "^2.0.0" "hasown": "^2.0.0"
}, },
@@ -2381,24 +2186,6 @@
"bignumber.js": "^9.0.0" "bignumber.js": "^9.0.0"
} }
}, },
"node_modules/json-buffer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
"integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="
},
"node_modules/json-stringify-safe": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz",
"integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA=="
},
"node_modules/jsontoxml": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/jsontoxml/-/jsontoxml-1.0.1.tgz",
"integrity": "sha512-dtKGq0K8EWQBRqcAaePSgKR4Hyjfsz/LkurHSV3Cxk4H+h2fWDeaN2jzABz+ZmOJylgXS7FGeWmbZ6jgYUMdJQ==",
"engines": {
"node": ">=0.2.0"
}
},
"node_modules/jsonwebtoken": { "node_modules/jsonwebtoken": {
"version": "9.0.2", "version": "9.0.2",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
@@ -2439,14 +2226,6 @@
"safe-buffer": "^5.0.1" "safe-buffer": "^5.0.1"
} }
}, },
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
"integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
"dependencies": {
"json-buffer": "3.0.1"
}
},
"node_modules/kleur": { "node_modules/kleur": {
"version": "4.1.5", "version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
@@ -2456,6 +2235,72 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/knex": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz",
"integrity": "sha512-GLoII6hR0c4ti243gMs5/1Rb3B+AjwMOfjYm97pu0FOQa7JH56hgBxYf5WK2525ceSbBY1cjeZ9yk99GPMB6Kw==",
"dependencies": {
"colorette": "2.0.19",
"commander": "^10.0.0",
"debug": "4.3.4",
"escalade": "^3.1.1",
"esm": "^3.2.25",
"get-package-type": "^0.1.0",
"getopts": "2.3.0",
"interpret": "^2.2.0",
"lodash": "^4.17.21",
"pg-connection-string": "2.6.2",
"rechoir": "^0.8.0",
"resolve-from": "^5.0.0",
"tarn": "^3.0.2",
"tildify": "2.0.0"
},
"bin": {
"knex": "bin/cli.js"
},
"engines": {
"node": ">=16"
},
"peerDependenciesMeta": {
"better-sqlite3": {
"optional": true
},
"mysql": {
"optional": true
},
"mysql2": {
"optional": true
},
"pg": {
"optional": true
},
"pg-native": {
"optional": true
},
"sqlite3": {
"optional": true
},
"tedious": {
"optional": true
}
}
},
"node_modules/knex/node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"engines": {
"node": ">=14"
}
},
"node_modules/knex/node_modules/resolve-from": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
"integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
"engines": {
"node": ">=8"
}
},
"node_modules/lilconfig": { "node_modules/lilconfig": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
@@ -2477,6 +2322,11 @@
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true "dev": true
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.includes": { "node_modules/lodash.includes": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
@@ -2512,17 +2362,6 @@
"resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
"integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg=="
}, },
"node_modules/lowercase-keys": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz",
"integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==",
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
@@ -2572,25 +2411,6 @@
"node": ">=8.6" "node": ">=8.6"
} }
}, },
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mimic-response": { "node_modules/mimic-response": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
@@ -2680,29 +2500,6 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}, },
"node_modules/musicbrainz-api": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/musicbrainz-api/-/musicbrainz-api-0.15.0.tgz",
"integrity": "sha512-PaL/it5DqreQPQBusxPuIIwRWedn51cK9NatIdHn654BS/DCHmsam2s65piD0gcjBokzKCAYFvEgJm4yLDRLBw==",
"dependencies": {
"@types/caseless": "^0.12.1",
"@types/request-promise-native": "^1.0.17",
"@types/uuid": "^9.0.0",
"caseless": "^0.12.0",
"debug": "^4.3.4",
"got": "^14.2.1",
"http-status-codes": "^2.1.4",
"json-stringify-safe": "^5.0.1",
"jsontoxml": "^1.0.1",
"rate-limit-threshold": "^0.1.5",
"source-map-support": "^0.5.16",
"tough-cookie": "^4.1.3",
"uuid": "^9.0.0"
},
"engines": {
"node": "^14.13.1 || >=16.0.0"
}
},
"node_modules/mz": { "node_modules/mz": {
"version": "2.7.0", "version": "2.7.0",
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
@@ -2791,17 +2588,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/normalize-url": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.1.tgz",
"integrity": "sha512-IO9QvjUMWxPQQhs60oOu10CRkWCiZzSUkzbXGGV9pviYl1fXYcvkzQ5jV9z8Y6un8ARoVRl4EtC6v6jNqbaJ/w==",
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/object-assign": { "node_modules/object-assign": {
"version": "4.1.1", "version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -2836,14 +2622,6 @@
"wrappy": "1" "wrappy": "1"
} }
}, },
"node_modules/p-cancelable": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-4.0.1.tgz",
"integrity": "sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==",
"engines": {
"node": ">=14.16"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -2877,8 +2655,7 @@
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
"dev": true
}, },
"node_modules/path-scurry": { "node_modules/path-scurry": {
"version": "1.10.1", "version": "1.10.1",
@@ -2907,6 +2684,11 @@
"is-reference": "^3.0.0" "is-reference": "^3.0.0"
} }
}, },
"node_modules/pg-connection-string": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.6.2.tgz",
"integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA=="
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz",
@@ -3208,11 +2990,6 @@
} }
} }
}, },
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",
"integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag=="
},
"node_modules/pump": { "node_modules/pump": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
@@ -3236,11 +3013,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/querystringify": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
"integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ=="
},
"node_modules/queue-microtask": { "node_modules/queue-microtask": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
@@ -3261,25 +3033,6 @@
} }
] ]
}, },
"node_modules/quick-lru": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz",
"integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rate-limit-threshold": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/rate-limit-threshold/-/rate-limit-threshold-0.1.5.tgz",
"integrity": "sha512-75vpvXC/ZqQJrFDp0dVtfoXZi8kxQP2eBuxVYFvGDfnHhcgE+ZG870u4ItQhWQh54Y6nNwOaaq5g3AL9n27lTg==",
"engines": {
"node": "^14.13.1 || >=16.0.0"
}
},
"node_modules/rc": { "node_modules/rc": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
@@ -3328,16 +3081,21 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/requires-port": { "node_modules/rechoir": {
"version": "1.0.0", "version": "0.8.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
"dependencies": {
"resolve": "^1.20.0"
},
"engines": {
"node": ">= 10.13.0"
}
}, },
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.8", "version": "1.22.8",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
"integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
"dev": true,
"dependencies": { "dependencies": {
"is-core-module": "^2.13.0", "is-core-module": "^2.13.0",
"path-parse": "^1.0.7", "path-parse": "^1.0.7",
@@ -3350,11 +3108,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/resolve-alpn": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz",
"integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g=="
},
"node_modules/resolve-from": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3364,20 +3117,6 @@
"node": ">=4" "node": ">=4"
} }
}, },
"node_modules/responselike": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz",
"integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==",
"dependencies": {
"lowercase-keys": "^3.0.0"
},
"engines": {
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/reusify": { "node_modules/reusify": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
@@ -3669,14 +3408,6 @@
"sorcery": "bin/sorcery" "sorcery": "bin/sorcery"
} }
}, },
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
@@ -3686,15 +3417,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/source-map-support": {
"version": "0.5.21",
"resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz",
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
"dependencies": {
"buffer-from": "^1.0.0",
"source-map": "^0.6.0"
}
},
"node_modules/string_decoder": { "node_modules/string_decoder": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
@@ -3891,7 +3613,6 @@
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
"dev": true,
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
}, },
@@ -4096,6 +3817,14 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tarn": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz",
"integrity": "sha512-51LAVKUSZSVfI05vjPESNc5vwqqZpbXCsU+/+wxlOrUjk2SnFTt97v9ZgQrD4YmxYW1Px6w2KjaDitCfkvgxMQ==",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
@@ -4117,6 +3846,14 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/tildify": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/tildify/-/tildify-2.0.0.tgz",
"integrity": "sha512-Cc+OraorugtXNfs50hU9KS369rFXCfgGLpfCfvlc+Ud5u6VWmUQsOAa9HbTvheQdYnrdJqqv1e5oIqXppMYnSw==",
"engines": {
"node": ">=8"
}
},
"node_modules/tiny-glob": { "node_modules/tiny-glob": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@@ -4148,28 +3885,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/tough-cookie": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz",
"integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==",
"dependencies": {
"psl": "^1.1.33",
"punycode": "^2.1.1",
"universalify": "^0.2.0",
"url-parse": "^1.5.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/tough-cookie/node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
"integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
"engines": {
"node": ">=6"
}
},
"node_modules/tr46": { "node_modules/tr46": {
"version": "0.0.3", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
@@ -4216,14 +3931,6 @@
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="
}, },
"node_modules/universalify": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/universalify/-/universalify-0.2.0.tgz",
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"engines": {
"node": ">= 4.0.0"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.0.13", "version": "1.0.13",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
@@ -4254,15 +3961,6 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/url-parse": {
"version": "1.5.10",
"resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz",
"integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==",
"dependencies": {
"querystringify": "^2.1.1",
"requires-port": "^1.0.0"
}
},
"node_modules/url-template": { "node_modules/url-template": {
"version": "2.0.8", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz", "resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
@@ -4492,6 +4190,14 @@
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }
},
"node_modules/zod": {
"version": "3.23.8",
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
"integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
} }
} }
} }

View File

@@ -28,13 +28,14 @@
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^6.5.1", "@fortawesome/fontawesome-free": "^6.5.1",
"@types/better-sqlite3": "^7.6.8", "@types/better-sqlite3": "^7.6.10",
"@types/jsonwebtoken": "^9.0.5", "@types/jsonwebtoken": "^9.0.5",
"bcrypt-ts": "^5.0.1", "bcrypt-ts": "^5.0.1",
"better-sqlite3": "^9.3.0", "better-sqlite3": "^9.3.0",
"fast-average-color": "^9.4.0", "fast-average-color": "^9.4.0",
"googleapis": "^133.0.0", "googleapis": "^133.0.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"musicbrainz-api": "^0.15.0" "knex": "^3.1.0",
"zod": "^3.23.8"
} }
} }

82
src/app.d.ts vendored
View File

@@ -18,6 +18,13 @@ declare global {
// Do not store data from other services in the database, only the data necessary to fetch whatever you need. // Do not store data from other services in the database, only the data necessary to fetch whatever you need.
// This avoid syncronization issues. E.g. Store userId, and urlOrigin to fetch the user's name and profile picture. // This avoid syncronization issues. E.g. Store userId, and urlOrigin to fetch the user's name and profile picture.
// Note to self: POST vs PUT vs PATCH
// Use POST when a new resource is being created
// Use PUT when a resource is being replaced. Semantically, PUT means the entire replacement resource needs to be provided in the request
// Use PATCH when a resource is being changed or updated. Semantically, PATCH means only a partial resource needs to be provided in the request (The parts being updated/changed)
type ConnectionType = 'jellyfin' | 'youtube-music'
type User = { type User = {
id: string id: string
username: string username: string
@@ -59,57 +66,61 @@ declare global {
public readonly id: string public readonly id: string
/** Retireves general information about the connection */ /** Retireves general information about the connection */
getConnectionInfo: () => Promise<ConnectionInfo> getConnectionInfo(): Promise<ConnectionInfo>
/** Get's the user's recommendations from the corresponding service */ /** Get's the user's recommendations from the corresponding service */
getRecommendations: () => Promise<(Song | Album | Artist | Playlist)[]> getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]>
/** /**
* @param searchTerm The string of text to query * @param {string} searchTerm The string of text to query
* @param filter Optional. A string of either 'song', 'album', 'artist', or 'playlist' to filter the kind of media items queried * @param {'song' | 'album' | 'artist' | 'playlist'} filter Optional. A string of either 'song', 'album', 'artist', or 'playlist' to filter the kind of media items queried
* @returns A promise of an array of media items * @returns {Promise<(Song | Album | Artist | Playlist)[]>} A promise of an array of media items
*/ */
search: <T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T) => Promise<SearchFilterMap<T>[]> search<T extends 'song' | 'album' | 'artist' | 'playlist'>(searchTerm: string, filter?: T): Promise<SearchFilterMap<T>[]>
/** /**
* @param id The id of the requested song * @param {string} id The id of the requested song
* @param headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range'). * @param {Headers} headers The request headers sent by the Lazuli client that need to be relayed to the connection's request to the server (e.g. 'range').
* @returns A promise of response object containing the audio stream for the specified byte range * @returns {Promise<Response>} A promise of response object containing the audio stream for the specified byte range
* *
* Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error. * Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error.
*/ */
getAudioStream: (id: string, headers: Headers) => Promise<Response> getAudioStream(id: string, headers: Headers): Promise<Response>
/** /**
* @param id The id of an album * @param {string} id The id of an album
* @returns A promise of the album as an Album object * @returns {Promise<Album>} A promise of the album as an Album object
*/ */
getAlbum: (id: string) => Promise<Album> getAlbum(id: string): Promise<Album>
/** /**
* @param id The id of an album * @param {string} id The id of an album
* @returns A promise of the songs in the album as and array of Song objects * @returns {Promise<Song[]>} A promise of the songs in the album as and array of Song objects
*/ */
getAlbumItems: (id: string) => Promise<Song[]> getAlbumItems(id: string): Promise<Song[]>
/** /**
* @param id The id of a playlist * @param {string} id The id of a playlist
* @returns A promise of the playlist of as a Playlist object * @returns {Promise<Playlist>} A promise of the playlist of as a Playlist object
*/ */
getPlaylist: (id: string) => Promise<Playlist> getPlaylist(id: string): Promise<Playlist>
/** /**
* @param id The id of a playlist * @param {string} id The id of a playlist
* @param startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results * @param {number} startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
* @param limit The maximum number of playlist items to return * @param {number} limit The maximum number of playlist items to return
* @returns A promise of the songs in the playlist as and array of Song objects * @returns {Promise<Song[]>} A promise of the songs in the playlist as and array of Song objects
*/ */
getPlaylistItems: (id: string, options?: { startIndex?: number, limit?: number }) => Promise<Song[]> getPlaylistItems(id: string, options?: { startIndex?: number, limit?: number }): Promise<Song[]>
public readonly songs?: { // Optional because YouTube Music can't be asked to provide an actually useful API.
songs(ids: string[]): Promise<Song[]>
}
public readonly library: { public readonly library: {
albums: () => Promise<Album[]> albums(): Promise<Album[]>
artists: () => Promise<Artist[]> artists(): Promise<Artist[]>
playlists: () => Promise<Playlist[]> playlists(): Promise<Playlist[]>
} }
} }
@@ -123,7 +134,7 @@ declare global {
type Song = { type Song = {
connection: { connection: {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: ConnectionType
} }
id: string id: string
name: string name: string
@@ -150,7 +161,7 @@ declare global {
type Album = { type Album = {
connection: { connection: {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: ConnectionType
} }
id: string id: string
name: string name: string
@@ -167,7 +178,7 @@ declare global {
type Artist = { type Artist = {
connection: { connection: {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: ConnectionType
} }
id: string id: string
name: string name: string
@@ -178,7 +189,7 @@ declare global {
type Playlist = { // Keep Playlist items seperate from the playlist itself. What's really nice is playlist items can just be an ordered array of Songs type Playlist = { // Keep Playlist items seperate from the playlist itself. What's really nice is playlist items can just be an ordered array of Songs
connection: { connection: {
id: string id: string
type: 'jellyfin' | 'youtube-music' type: ConnectionType
} }
id: string id: string
name: string name: string
@@ -190,6 +201,15 @@ declare global {
} }
} }
type Mix = {
id: string
name: string
thumbnail?: string
description?: string
trackCount: number
duration: number
}
type HasDefinedProperty<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], undefined> }; type HasDefinedProperty<T, K extends keyof T> = T & { [P in K]-?: Exclude<T[P], undefined> };
} }

View File

@@ -1,5 +1,6 @@
import { redirect, type Handle, type HandleFetch, type RequestEvent } from '@sveltejs/kit' import { redirect, type Handle, type RequestEvent } from '@sveltejs/kit'
import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private' import { SECRET_INTERNAL_API_KEY, SECRET_JWT_KEY } from '$env/static/private'
import { userExists, mixExists } from '$lib/server/api-helper'
import jwt from 'jsonwebtoken' import jwt from 'jsonwebtoken'
function verifyAuthToken(event: RequestEvent) { function verifyAuthToken(event: RequestEvent) {
@@ -14,18 +15,30 @@ function verifyAuthToken(event: RequestEvent) {
} }
} }
const unauthorizedResponse = new Response('Unauthorized.', { status: 401 })
const userNotFoundResponse = new Response('User not found.', { status: 404 })
const mixNotFoundResponse = new Response('Mix not found.', { status: 404 })
// * Custom Handle specifically for requests made to the API endpoint. Handles authorization and any other middleware verifications
const handleAPIRequest: Handle = async ({ event, resolve }) => {
const authorized = event.request.headers.get('apikey') === SECRET_INTERNAL_API_KEY || event.url.searchParams.get('apikey') === SECRET_INTERNAL_API_KEY || verifyAuthToken(event)
if (!authorized) unauthorizedResponse
const userId = event.params.userId
if (userId && !(await userExists(userId))) return userNotFoundResponse
const mixId = event.params.mixId
if (mixId && !(await mixExists(mixId))) return mixNotFoundResponse
return resolve(event)
}
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
const urlpath = event.url.pathname const urlpath = event.url.pathname
if (urlpath.startsWith('/login')) return resolve(event) if (urlpath.startsWith('/login')) return resolve(event)
if (urlpath.startsWith('/api')) { if (urlpath.startsWith('/api')) return handleAPIRequest({ event, resolve })
if (event.request.headers.get('apikey') === SECRET_INTERNAL_API_KEY || event.url.searchParams.get('apikey') === SECRET_INTERNAL_API_KEY || verifyAuthToken(event)) {
return resolve(event)
}
return new Response('Unauthorized', { status: 401 })
}
const authToken = event.cookies.get('lazuli-auth') const authToken = event.cookies.get('lazuli-auth')
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`) if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
@@ -39,9 +52,3 @@ export const handle: Handle = async ({ event, resolve }) => {
return resolve(event) return resolve(event)
} }
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
const authorized = verifyAuthToken(event)
return authorized ? fetch(request) : new Response('Unauthorized', { status: 401 })
}

View File

@@ -12,18 +12,16 @@
export let linked = true export let linked = true
</script> </script>
<div class="break-keep"> <div class="break-words break-keep">
{#if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists === 'string'} {#if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists === 'string'}
{mediaItem.artists} {mediaItem.artists}
{:else if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists !== 'string' && mediaItem.artists.length > 0} {:else if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists !== 'string' && mediaItem.artists.length > 0}
{#each mediaItem.artists as artist, index} {#each mediaItem.artists as artist, index}
{@const needsComma = index < mediaItem.artists.length - 1}
{#if linked} {#if linked}
<a class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a> <a class:needsComma class="hover:underline focus:underline" href="/details/artist?id={artist.id}&connection={mediaItem.connection.id}">{artist.name}</a>
{:else} {:else}
<span>{artist.name}</span> <span class:needsComma class="artist-name">{artist.name}</span>
{/if}
{#if index < mediaItem.artists.length - 1}
<span style="margin-left: -0.25em; margin-right: 0.25em">&#44;</span>
{/if} {/if}
{/each} {/each}
{:else if 'uploader' in mediaItem && mediaItem.uploader} {:else if 'uploader' in mediaItem && mediaItem.uploader}
@@ -40,3 +38,10 @@
{/if} {/if}
{/if} {/if}
</div> </div>
<style>
.needsComma::after {
content: ',';
margin-right: 0.25em;
}
</style>

View File

@@ -22,11 +22,12 @@
let imageContainer: HTMLDivElement let imageContainer: HTMLDivElement
// TODO: Implement auto-resizing
function updateImage(newThumbnailURL: string) { function updateImage(newThumbnailURL: string) {
if (!imageContainer) return if (!imageContainer) return
const width = imageContainer.clientWidth const width = imageContainer.clientWidth * 1.5 // 1.5x is a good compromise between sharpness and performance
const height = imageContainer.clientHeight const height = imageContainer.clientHeight * 1.5
const newImage = new Image(width, height) const newImage = new Image(width, height)
imageContainer.appendChild(newImage) imageContainer.appendChild(newImage)
@@ -57,8 +58,8 @@
} }
newImage.onerror = () => { newImage.onerror = () => {
removeOldImage() console.error(`Image from url: ${newThumbnailURL} failed to update`)
newImage.style.opacity = '1' imageContainer.removeChild(newImage)
} }
} }

View File

@@ -10,6 +10,10 @@
import ScrollingText from '$lib/components/util/scrollingText.svelte' import ScrollingText from '$lib/components/util/scrollingText.svelte'
import ArtistList from './artistList.svelte' import ArtistList from './artistList.svelte'
// NEW IDEA: Only have the miniplayer for controls and for the expanded view just make it one large Videoplayer.
// That way we can target the player to be the size of YouTube's default player. Then move the Queue view to it's own
// dedicated sidebar like in spotify.
$: currentlyPlaying = $queue.current $: currentlyPlaying = $queue.current
let expanded = false let expanded = false
@@ -45,15 +49,15 @@
navigator.mediaSession.metadata = new MediaMetadata({ navigator.mediaSession.metadata = new MediaMetadata({
title: media.name, title: media.name,
artist: media.artists?.map((artist) => artist.name).join(', ') || media.uploader?.name, artist: media.artists?.map((artist) => artist.name).join(', ') ?? media.uploader?.name,
album: media.album?.name, album: media.album?.name,
artwork: [ artwork: [
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96', type: 'image/png' }, { src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128', type: 'image/png' }, { src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192', type: 'image/png' }, { src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256', type: 'image/png' }, { src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384', type: 'image/png' }, { src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512', type: 'image/png' }, { src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
], ],
}) })
} }
@@ -103,7 +107,7 @@
{#if !expanded} {#if !expanded}
<main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10 pr-8"> <main in:fade={{ duration: 75, delay: 500 }} out:fade={{ duration: 75 }} class="flex h-20 w-full gap-10 pr-8">
<section class="flex w-80 gap-3"> <section class="flex w-80 gap-3">
<div class="relative h-full w-20 min-w-20"> <div class="relative h-full w-20 min-w-20 overflow-clip rounded-xl">
<LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} /> <LazyImage thumbnailUrl={currentlyPlaying.thumbnailUrl} alt={`${currentlyPlaying.name} jacket`} objectFit={'cover'} />
</div> </div>
<section class="flex flex-grow flex-col justify-center gap-1"> <section class="flex flex-grow flex-col justify-center gap-1">
@@ -121,16 +125,14 @@
<IconButton on:click={() => $queue.previous()}> <IconButton on:click={() => $queue.previous()}>
<i slot="icon" class="fa-solid fa-backward-step text-xl" /> <i slot="icon" class="fa-solid fa-backward-step text-xl" />
</IconButton> </IconButton>
<div class="aspect-square h-full rounded-full border border-neutral-700"> <div class="relative aspect-square h-full rounded-full border border-neutral-700">
<IconButton on:click={() => (paused = !paused)}>
<div slot="icon">
{#if waiting} {#if waiting}
<Loader size={1.5} /> <Loader size={1.5} />
{:else} {:else}
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" /> <IconButton on:click={() => (paused = !paused)}>
{/if} <i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
</div>
</IconButton> </IconButton>
{/if}
</div> </div>
<IconButton on:click={() => $queue.clear()}> <IconButton on:click={() => $queue.clear()}>
<i slot="icon" class="fa-solid fa-stop text-xl" /> <i slot="icon" class="fa-solid fa-stop text-xl" />
@@ -156,7 +158,10 @@
</div> </div>
</section> </section>
<section class="flex items-center justify-end gap-2.5 py-6 text-lg"> <section class="flex items-center justify-end gap-2.5 py-6 text-lg">
<div id="volume-slider" class="mx-4 flex h-10 w-44 flex-row-reverse items-center gap-3"> <div id="volume-slider" class="mx-4 flex h-10 w-44 items-center gap-3">
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
<Slider <Slider
bind:value={volume} bind:value={volume}
max={maxVolume} max={maxVolume}
@@ -164,9 +169,6 @@
if (volume > 0) localStorage.setItem('volume', volume.toString()) if (volume > 0) localStorage.setItem('volume', volume.toString())
}} }}
/> />
<IconButton on:click={() => (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}>
<i slot="icon" class="fa-solid {volume > maxVolume / 2 ? 'fa-volume-high' : volume > 0 ? 'fa-volume-low' : 'fa-volume-xmark'}" />
</IconButton>
</div> </div>
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}> <IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" /> <i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
@@ -213,7 +215,7 @@
</section> </section>
</section> </section>
<section class="px-8"> <section class="px-8">
<div id="progress-bar-expanded" class="mb-7"> <div id="progress-bar-expanded" class="mb-6">
<span bind:this={expandedCurrentTimeTimestamp} class="text-right" /> <span bind:this={expandedCurrentTimeTimestamp} class="text-right" />
<Slider <Slider
bind:this={expandedProgressBar} bind:this={expandedProgressBar}
@@ -324,7 +326,7 @@
} }
#expanded-player { #expanded-player {
display: grid; display: grid;
grid-template-rows: calc(100% - 12rem) 12rem; grid-template-rows: calc(100% - 11rem) 11rem;
} }
#song-queue-wrapper { #song-queue-wrapper {
display: grid; display: grid;

View File

@@ -1,23 +0,0 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let icon: string
export let label: string
export let redirect: string
export let disabled: boolean
</script>
<button {disabled} class="block w-full py-2 text-left" on:click={() => goto(redirect)}>
<span class:disabled class="py-1 pl-8 {disabled ? 'text-lazuli-primary' : 'text-neutral-300'}">
<i class="{icon} mr-1.5 h-5 w-5" />
{label}
</span>
</button>
<style>
span.disabled {
border-left: 2px solid var(--lazuli-primary);
background: linear-gradient(to right, var(--lazuli-primary) -25%, transparent 25%);
}
</style>

View File

@@ -0,0 +1,42 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let color: string
export let name: string
export let id: string
export let disabled: boolean = false
</script>
<button style="--mix-color: {color};" {disabled} class="block w-full overflow-hidden text-ellipsis py-3 text-left" on:click={() => goto(`/mixes/${id}`)}>
<span class:disabled class="relative text-nowrap py-1 pl-6">{name}</span>
</button>
<style>
span:hover {
color: var(--mix-color);
}
span.disabled {
color: var(--mix-color);
}
span::before {
content: '•';
margin-right: 0.75rem;
font-weight: 900;
color: var(--mix-color);
}
span::after {
content: '';
width: 100%;
height: 0;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
background: linear-gradient(to right, var(--mix-color) 2px, color-mix(in srgb, var(--mix-color), transparent) 2px, transparent 20%);
transition: height 100ms linear;
}
span.disabled::after {
height: 100%;
}
</style>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import { goto } from '$app/navigation'
export let icon: string
export let label: string
export let redirect: string
export let disabled: boolean = false
</script>
<button {disabled} class="block w-full overflow-hidden text-ellipsis py-2 text-left" on:click={() => goto(redirect)}>
<span class:disabled class="relative text-nowrap py-1 pl-6 text-neutral-300">
<i class="{icon} mr-1.5 h-5 w-5" />
{label}
</span>
</button>
<style>
span:hover {
color: var(--lazuli-primary);
}
span.disabled {
color: var(--lazuli-primary);
}
span::before {
content: '';
width: 100%;
height: 0;
position: absolute;
top: 50%;
left: 0;
transform: translateY(-50%);
background: linear-gradient(to right, var(--lazuli-primary) 2px, color-mix(in srgb, var(--lazuli-primary), transparent) 2px, transparent 20%);
transition: height 100ms linear;
}
span.disabled:before {
height: 100%;
}
</style>

View File

@@ -26,7 +26,7 @@
<div <div
id="slider-track" id="slider-track"
class="relative isolate h-1.5 w-full rounded-full bg-neutral-600" class="relative isolate h-1 w-full rounded bg-neutral-600"
style="--slider-color: var(--lazuli-primary)" style="--slider-color: var(--lazuli-primary)"
role="slider" role="slider"
tabindex="0" tabindex="0"
@@ -39,7 +39,7 @@
on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })} on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })}
on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })} on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })}
type="range" type="range"
class="absolute z-10 h-1.5 w-full" class="absolute z-10 h-1 w-full"
step="any" step="any"
min="0" min="0"
{max} {max}
@@ -48,7 +48,7 @@
aria-hidden="true" aria-hidden="true"
aria-disabled="true" aria-disabled="true"
/> />
<span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1.5 rounded-full bg-white transition-colors" /> <span bind:this={sliderTrail} id="slider-trail" class="absolute left-0 h-1 rounded-full bg-white transition-colors" />
<span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" /> <span bind:this={sliderThumb} id="slider-thumb" class="absolute top-1/2 aspect-square h-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white opacity-0 transition-opacity duration-300" />
</div> </div>

View File

@@ -0,0 +1,48 @@
import { DB, type DBSchemas } from './db'
import { Jellyfin } from './jellyfin'
import { YouTubeMusic } from './youtube-music'
export async function userExists(userId: string): Promise<boolean> {
return Boolean(await DB.users.where('id', userId).first(DB.knex.raw('EXISTS(SELECT 1)')))
}
export async function mixExists(mixId: string): Promise<Boolean> {
return Boolean(await DB.mixes.where('id', mixId).first(DB.knex.raw('EXISTS(SELECT 1)')))
}
function connectionBuilder(schema: DBSchemas.Connections): Connection {
const { id, userId, type, serviceUserId, accessToken } = schema
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, serviceUserId, schema.serverUrl, accessToken)
case 'youtube-music':
return new YouTubeMusic(id, userId, serviceUserId, accessToken, schema.refreshToken, schema.expiry)
}
}
/**
* Queries the database for a specific connection.
*
* @param id The id of the connection
* @returns An instance of a Connection
* @throws ReferenceError if there is no connection with an id matches the one passed
*/
export async function buildConnection(id: string): Promise<Connection> {
const schema = await DB.connections.where('id', id).first()
if (!schema) throw ReferenceError(`Connection of Id ${id} does not exist`)
return connectionBuilder(schema)
}
/**
* Queries the database for all connections belong to a user of the specified id.
*
* @param userId The id of a user
* @returns An array of connection instances for each of the user's connections
* @throws ReferenceError if there is no user with an id matches the one passed
*/
export async function buildUserConnections(userId: string): Promise<Connection[]> {
if (!(await userExists(userId))) throw ReferenceError(`User of Id ${userId} does not exist`)
return (await DB.connections.where('userId', userId).select('*')).map(connectionBuilder)
}

View File

@@ -1,27 +0,0 @@
import { DB } from './db'
import { Jellyfin } from './jellyfin'
import { YouTubeMusic } from './youtube-music'
const constructConnection = (connectionInfo: ReturnType<typeof DB.getConnectionInfo>): Connection | undefined => {
if (!connectionInfo) return undefined
const { id, userId, type, service, tokens } = connectionInfo
switch (type) {
case 'jellyfin':
return new Jellyfin(id, userId, service.userId, service.serverUrl, tokens.accessToken)
case 'youtube-music':
return new YouTubeMusic(id, userId, service.userId, tokens.accessToken, tokens.refreshToken, tokens.expiry)
}
}
function getConnection(id: string): Connection | undefined {
return constructConnection(DB.getConnectionInfo(id))
}
const getUserConnections = (userId: string): Connection[] | undefined => {
return DB.getUserConnectionInfo(userId)?.map((info) => constructConnection(info)!)
}
export const Connections = {
getConnection,
getUserConnections,
}

View File

@@ -1,132 +1,187 @@
import Database from 'better-sqlite3' import knex from 'knex'
import type { Database as Sqlite3DB } from 'better-sqlite3' import { SqliteError } from 'better-sqlite3'
import { generateUUID } from '$lib/utils'
interface DBConnectionsTableSchema { const connectionTypes = ['jellyfin', 'youtube-music']
id: string
userId: string
type: string
service?: string
tokens?: string
}
export type ConnectionRow = { export declare namespace DBSchemas {
interface Users {
id: string id: string
userId: string username: string
} & ( passwordHash: string
| {
type: 'jellyfin'
service: {
userId: string
serverUrl: string
} }
tokens: {
interface JellyfinConnection {
id: string
userId: string
type: 'jellyfin'
serviceUserId: string
serverUrl: string
accessToken: string accessToken: string
} }
}
| { interface YouTubeMusicConnection {
type: 'youtube-music' id: string
service: {
userId: string userId: string
} type: 'youtube-music'
tokens: { serviceUserId: string
accessToken: string accessToken: string
refreshToken: string refreshToken: string
expiry: number expiry: number
} }
}
)
class Storage { type Connections = JellyfinConnection | YouTubeMusicConnection
private readonly database: Sqlite3DB
constructor(database: Sqlite3DB) { interface Mixes {
this.database = database id: string
this.database.pragma('foreign_keys = ON') userId: string
this.database.exec(`CREATE TABLE IF NOT EXISTS Users( name: string
id VARCHAR(36) PRIMARY KEY, thumbnailTag?: string
username VARCHAR(30) UNIQUE NOT NULL, description?: string
passwordHash VARCHAR(72) NOT NULL trackCount: number
)`) duration: number
this.database.exec(`CREATE TABLE IF NOT EXISTS Connections(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
type VARCHAR(36) NOT NULL,
service TEXT,
tokens TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`)
this.database.exec(`CREATE TABLE IF NOT EXISTS Playlists(
id VARCHAR(36) PRIMARY KEY,
userId VARCHAR(36) NOT NULL,
name TEXT NOT NULL,
description TEXT,
items TEXT,
FOREIGN KEY(userId) REFERENCES Users(id)
)`)
} }
public getUser = (id: string): User | undefined => { interface MixItems {
const user = this.database.prepare(`SELECT * FROM Users WHERE id = ? LIMIT 1`).get(id) as User | undefined mixId: string
return user connectionId: string
connectionType: ConnectionType
id: string
index: number
} }
public getUsername = (username: string): User | undefined => { interface Songs {
const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ? LIMIT 1`).get(username.toLowerCase()) as User | undefined connectionId: string
return user connectionType: ConnectionType
id: string
name: string
duration: number
thumbnailUrl: string
releaseDate?: string
artists?: {
id: string
name: string
}[]
album?: {
id: string
name: string
} }
uploader?: {
public addUser = (username: string, passwordHash: string): User => { id: string
const userId = generateUUID() name: string
this.database.prepare(`INSERT INTO Users(id, username, passwordHash) VALUES(?, ?, ?)`).run(userId, username, passwordHash)
return this.getUser(userId)!
} }
isVideo: boolean
public deleteUser = (id: string): void => {
const commandInfo = this.database.prepare(`DELETE FROM Users WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`User with id ${id} does not exist`)
}
public getConnectionInfo = (id: string): ConnectionRow | undefined => {
const result = this.database.prepare(`SELECT * FROM Connections WHERE id = ? LIMIT 1`).get(id) as DBConnectionsTableSchema | undefined
if (!result) return undefined
const { userId, type, service, tokens } = result
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
return { id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens }
}
public getUserConnectionInfo = (userId: string): ConnectionRow[] | undefined => {
const user = this.getUser(userId)
if (!user) return undefined
const connectionRows = this.database.prepare(`SELECT * FROM Connections WHERE userId = ?`).all(userId) as DBConnectionsTableSchema[]
const connections: ConnectionRow[] = []
for (const { id, type, service, tokens } of connectionRows) {
const parsedService = service ? JSON.parse(service) : undefined
const parsedTokens = tokens ? JSON.parse(tokens) : undefined
connections.push({ id, userId, type: type as ConnectionRow['type'], service: parsedService, tokens: parsedTokens })
}
return connections
}
public addConnectionInfo = (connectionInfo: Omit<ConnectionRow, 'id'>): string => {
const { userId, type, service, tokens } = connectionInfo
const connectionId = generateUUID()
this.database.prepare(`INSERT INTO Connections(id, userId, type, service, tokens) VALUES(?, ?, ?, ?, ?)`).run(connectionId, userId, type, JSON.stringify(service), JSON.stringify(tokens))
return connectionId
}
public deleteConnectionInfo = (id: string): void => {
const commandInfo = this.database.prepare(`DELETE FROM Connections WHERE id = ?`).run(id)
if (commandInfo.changes === 0) throw new Error(`Connection with id: ${id} does not exist`)
}
public updateTokens = (id: string, tokens: ConnectionRow['tokens']): void => {
const commandInfo = this.database.prepare(`UPDATE Connections SET tokens = ? WHERE id = ?`).run(JSON.stringify(tokens), id)
if (commandInfo.changes === 0) throw new Error('Failed to update tokens')
} }
} }
export const DB = new Storage(new Database('./src/lib/server/lazuli.db', { verbose: console.info })) class Database {
public readonly knex: knex.Knex
constructor(knex: knex.Knex<'better-sqlite3'>) {
this.knex = knex
}
public uuid() {
return this.knex.fn.uuid()
}
public get users() {
return this.knex<DBSchemas.Users>('Users')
}
public get connections() {
return this.knex<DBSchemas.Connections>('Connections')
}
public get mixes() {
return this.knex<DBSchemas.Mixes>('Mixes')
}
public get mixItems() {
return this.knex<DBSchemas.MixItems>('MixItems')
}
public get songs() {
return this.knex<DBSchemas.Songs>('Songs')
}
public get sqliteError() {
return SqliteError
}
public static async createUsersTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Users')
if (exists) return
await db.schema.createTable('Users', (tb) => {
tb.uuid('id').primary(), tb.string('username').unique().notNullable().checkLength('<=', 30), tb.string('passwordHash').notNullable().checkLength('=', 60)
})
}
public static async createConnectionsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Connections')
if (exists) return
await db.schema.createTable('Connections', (tb) => {
tb.uuid('id').primary(),
tb.uuid('userId').notNullable().references('id').inTable('Users'),
tb.enum('type', connectionTypes).notNullable(),
tb.string('serviceUserId'),
tb.string('serverUrl'),
tb.string('accessToken'),
tb.string('refreshToken'),
tb.integer('expiry')
})
}
public static async createMixesTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Mixes')
if (exists) return
await db.schema.createTable('Mixes', (tb) => {
tb.uuid('id').primary(),
tb.uuid('userId').notNullable().references('id').inTable('Users'),
tb.string('name').notNullable(),
tb.uuid('thumbnailTag'),
tb.string('description'),
tb.integer('trackCount').notNullable(),
tb.integer('duration').notNullable()
})
}
public static async createMixItemsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('MixItems')
if (exists) return
await db.schema.createTable('MixItems', (tb) => {
tb.uuid('mixId').notNullable().references('id').inTable('Mixes'),
tb.uuid('connectionId').notNullable().references('id').inTable('Connections'),
tb.enum('connectionType', connectionTypes).notNullable(),
tb.string('id').notNullable()
tb.integer('index').notNullable()
})
}
public static async createSongsTable(db: knex.Knex<'better-sqlite3'>) {
const exists = await db.schema.hasTable('Songs')
if (exists) return
await db.schema.createTable('Songs', (tb) => {
tb.uuid('connectionId').notNullable().references('id').inTable('Connections'),
tb.enum('connectionType', connectionTypes),
tb.string('id').notNullable(),
tb.string('name').notNullable(),
tb.integer('duration').notNullable(),
tb.string('thumbnailUrl').notNullable(),
tb.datetime('releaseDate', { precision: 3 }),
tb.json('artists'),
tb.json('album'),
tb.json('uploader'),
tb.boolean('isVideo').notNullable()
})
}
}
const db = knex<'better-sqlite3'>({ client: 'better-sqlite3', connection: { filename: './src/lib/server/lazuli.db' }, useNullAsDefault: false })
await Promise.all([Database.createUsersTable(db), Database.createConnectionsTable(db), Database.createMixesTable(db), Database.createMixItemsTable(db), Database.createSongsTable(db)])
export const DB = new Database(db)

View File

@@ -1,77 +0,0 @@
import { PUBLIC_VERSION } from '$env/static/public'
import { MusicBrainzApi } from 'musicbrainz-api'
const mbApi = new MusicBrainzApi({
appName: 'Lazuli',
appVersion: PUBLIC_VERSION,
appContactInfo: 'Ec1ypsed@proton.me',
})
async function potentialAliasesFromNames(artistNames: string[]) {
const luceneQuery = artistNames.join(' OR ')
const artistsResponse = await mbApi.search('artist', { query: luceneQuery })
const SCORE_THRESHOLD = 90
const possibleArtists = artistsResponse.artists.filter((artist) => artist.score >= SCORE_THRESHOLD)
const aliases = possibleArtists.flatMap((artist) => [artist.name].concat(artist.aliases?.filter((alias) => alias.primary !== null).map((alias) => alias.name) ?? []))
return [...new Set(aliases)] // Removes any duplicates
}
export class MusicBrainz {
static async searchRecording(songName: string, artistNames?: string[]) {
const standardSearchResults = await mbApi.search('recording', { query: songName, limit: 5 })
const SCORE_THRESHOLD = 90
const bestResults = standardSearchResults.recordings.filter((recording) => recording.score >= SCORE_THRESHOLD)
const artistAliases = artistNames ? await potentialAliasesFromNames(artistNames) : null
const luceneQuery = artistAliases ? `"${songName}"`.concat(` AND (${artistAliases.map((alias) => `artist:"${alias}"`).join(' OR ')})`) : `"${songName}"`
console.log(luceneQuery)
const searchResults = await mbApi.search('recording', { query: luceneQuery, limit: 1 })
if (searchResults.recordings.length === 0) {
console.log('Nothing returned for ' + songName)
return null
}
const topResult = searchResults.recordings[0]
// const bestMatch = searchResults.recordings.reduce((prev, current) => (prev.score > current.score ? prev : current))
console.log(JSON.stringify(topResult))
}
static async searchRelease(albumName: string, artistNames?: string[]): Promise<MusicBrainz.ReleaseSearchResult | null> {
const searchResulst = await mbApi.search('release', { query: albumName, limit: 10 })
if (searchResulst.releases.length === 0) {
console.log(JSON.stringify('Nothing returned for ' + albumName))
return null
}
const bestMatch = searchResulst.releases.reduce((prev, current) => {
if (prev.score === current.score) return new Date(prev.date).getTime() > new Date(current.date).getTime() ? prev : current
return prev.score > current.score ? prev : current
})
const { id, title, date } = bestMatch
const trackCount = bestMatch.media.reduce((acummulator, current) => acummulator + current['track-count'], 0)
const artists = bestMatch['artist-credit']?.map((artist) => ({ id: artist.artist.id, name: artist.artist.name }))
return { id, name: title, releaseDate: date, artists, trackCount } satisfies MusicBrainz.ReleaseSearchResult
}
static async searchArtist(artistName: string) {
const searchResults = await mbApi.search('artist', { query: artistName })
}
}
declare namespace MusicBrainz {
type ReleaseSearchResult = {
id: string
name: string
releaseDate: string
artists?: {
id: string
name: string
}[]
trackCount: number
}
}

View File

@@ -1,10 +1,10 @@
import { youtube, type youtube_v3 } from 'googleapis/build/src/apis/youtube' import { youtube, type youtube_v3 } from 'googleapis/build/src/apis/youtube'
import { DB } from './db'
import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public'
import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
import type { InnerTube } from './youtube-music-types' import type { InnerTube } from './youtube-music-types'
import { DB } from './db'
const ytDataApi = youtube('v3') const ytDataApi = youtube('v3') // TODO: At some point I want to ditch this package and just make the API calls directly. Fewer dependecies
type ytMusicv1ApiRequestParams = type ytMusicv1ApiRequestParams =
| { | {
@@ -55,7 +55,10 @@ export class YouTubeMusic implements Connection {
} }
public async getConnectionInfo() { public async getConnectionInfo() {
const access_token = await this.requestManager.accessToken.catch(() => null) const access_token = await this.requestManager.accessToken.catch(() => {
console.log('Failed to get yt access token')
return null
})
let username: string | undefined, profilePicture: string | undefined let username: string | undefined, profilePicture: string | undefined
if (access_token) { if (access_token) {
@@ -515,6 +518,43 @@ export class YouTubeMusic implements Connection {
} }
}) as ScrapedMediaItemMap<T[number]>[] }) as ScrapedMediaItemMap<T[number]>[]
} }
// ! HOLY FUCK HOLY FUCK THIS IS IT!!!! THIS IS HOW YOU CAN BATCH FETCH FULL DETAILS FOR COMPLETELY UNRELATED SONGS IN ONE API CALL!!!!
// ! IT GIVES BACK FUCKING EVERYTHING (almost)! NAME, ALBUM, ARTISTS, UPLOADER, DURATION, THUMBNAIL.
// ! The only thing kinda missing is release date, but that could be fetched from the official API. In fact I'll already need to make a call to
// ! the offical API to get the thumbnails for the videos any way. And since you can batch call that one, you won't be making any extra queries just
// ! to get the release date. HOLY FUCK THIS IS PERFECT! (something is going to go wrong in the future for sure)
private async testMethod(videoIds: string[]) {
const currentDate = new Date()
const year = currentDate.getUTCFullYear().toString()
const month = (currentDate.getUTCMonth() + 1).toString().padStart(2, '0') // Months are zero-based, so add 1
const day = currentDate.getUTCDate().toString().padStart(2, '0')
const response = await fetch('https://music.youtube.com/youtubei/v1/music/get_queue', {
headers: {
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0',
authorization: `Bearer ${await this.requestManager.accessToken}`,
},
method: 'POST',
body: JSON.stringify({
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: `1.${year + month + day}.01.00`,
},
},
videoIds,
}),
})
if (!response.ok) {
console.log(response)
return
}
const data = await response.json()
console.log(JSON.stringify(data))
}
} }
function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist { function parseTwoRowItemRenderer(rowContent: InnerTube.musicTwoRowItemRenderer): InnerTube.ScrapedSong | InnerTube.ScrapedAlbum | InnerTube.ScrapedArtist | InnerTube.ScrapedPlaylist {
@@ -742,8 +782,8 @@ class YTRequestManager {
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
this.accessTokenRefreshRequest = refreshAccessToken() this.accessTokenRefreshRequest = refreshAccessToken()
.then(({ accessToken, expiry }) => { .then(async ({ accessToken, expiry }) => {
DB.updateTokens(this.connectionId, { accessToken, refreshToken: this.refreshToken, expiry }) await DB.connections.where('id', this.connectionId).update('tokens', { accessToken, refreshToken: this.refreshToken, expiry })
this.currentAccessToken = accessToken this.currentAccessToken = accessToken
this.expiry = expiry this.expiry = expiry
this.accessTokenRefreshRequest = null this.accessTokenRefreshRequest = null

View File

@@ -1,14 +1,32 @@
<script lang="ts"> <script lang="ts">
import SearchBar from '$lib/components/util/searchBar.svelte' import SearchBar from '$lib/components/util/searchBar.svelte'
import type { LayoutData } from './$types' import type { LayoutData } from './$types'
import NavTab from '$lib/components/navbar/navTab.svelte' import NavTab from '$lib/components/util/navTab.svelte'
import MixTab from '$lib/components/util/mixTab.svelte'
import MediaPlayer from '$lib/components/media/mediaPlayer.svelte' import MediaPlayer from '$lib/components/media/mediaPlayer.svelte'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
export let data: LayoutData export let data: LayoutData
let mixData = [
{
name: 'J-Core Mix',
color: 'red',
id: 'SomeId',
},
{
name: 'Best of: 葉月ゆら',
color: 'purple',
id: 'SomeId',
},
]
$: currentPathname = data.url.pathname $: currentPathname = data.url.pathname
let newMixNameInputOpen = false
// I'm thinking I might want to make /albums, /artists, and /playlists all there own routes and just wrap them in a (library) layout
</script> </script>
<main id="grid-wrapper" class="h-full"> <main id="grid-wrapper" class="h-full">
@@ -24,10 +42,23 @@
</IconButton> </IconButton>
</div> </div>
</nav> </nav>
<section id="sidebar" class="pt-4 font-light"> <section id="sidebar" class="relative pt-4 text-sm font-normal">
<NavTab label={'Home'} icon={'fa-solid fa-wave-square'} redirect={'/'} disabled={currentPathname === '/'} /> <div class="mb-10">
<NavTab label={'Playlists'} icon={'fa-solid fa-bars-staggered'} redirect={'/playlists'} disabled={/^\/playlists.*$/.test(currentPathname)} /> <NavTab label="Home" icon="fa-solid fa-wave-square" redirect="/" disabled={currentPathname === '/'} />
<NavTab label={'Library'} icon={'fa-solid fa-book'} redirect={'/library'} disabled={/^\/library.*$/.test(currentPathname)} /> <NavTab label="Playlists" icon="fa-solid fa-bars-staggered" redirect="/playlists" disabled={/^\/playlists.*$/.test(currentPathname)} />
<NavTab label="Library" icon="fa-solid fa-book" redirect="/library" disabled={/^\/library.*$/.test(currentPathname)} />
</div>
<h1 class="mb-1 flex h-5 items-center justify-between pl-6 text-sm text-neutral-400">
Your Mixes
<IconButton halo={true} on:click={() => (mixData = [{ name: 'New Mix', color: 'grey', id: 'SomeId' }, ...mixData])}>
<i slot="icon" class="fa-solid fa-plus" />
</IconButton>
</h1>
<div>
{#each mixData as mix}
<MixTab {...mix} />
{/each}
</div>
</section> </section>
<section id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8"> <section id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8">
<slot /> <slot />
@@ -39,8 +70,8 @@
#grid-wrapper, #grid-wrapper,
#navbar { #navbar {
display: grid; display: grid;
column-gap: 1rem; column-gap: 3rem;
grid-template-columns: 14rem auto 14rem; grid-template-columns: 12rem auto 12rem;
} }
#grid-wrapper { #grid-wrapper {
@@ -54,7 +85,4 @@
#sidebar { #sidebar {
grid-area: 2 / 1 / 3 / 2; grid-area: 2 / 1 / 3 / 2;
} }
#content-wrapper {
grid-area: 2 / 2 / 3 / 4;
}
</style> </style>

View File

@@ -10,7 +10,7 @@
$: currentPathname = data.url.pathname $: currentPathname = data.url.pathname
</script> </script>
<main class="py-8"> <main class="py-4">
<nav id="nav-options" class="mb-8 flex h-12 justify-between"> <nav id="nav-options" class="mb-8 flex h-12 justify-between">
<section class="relative flex h-full gap-4"> <section class="relative flex h-full gap-4">
<button disabled={/^\/library$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library')}>History</button> <button disabled={/^\/library$/.test(currentPathname)} class="library-tab h-full px-1" on:click={() => goto('/library')}>History</button>

View File

@@ -0,0 +1,5 @@
import type { PageServerLoad } from './$types'
export const load: PageServerLoad = async ({ locals }) => {
return { user: locals.user }
}

View File

@@ -1 +1,18 @@
<h1>This would be a good place for listen history</h1> <script lang="ts">
import type { PageServerData } from './$types.js'
export let data: PageServerData
async function testRequest() {
const mixData = await fetch(`/api/v1/users/${data.user.id}fkaskdkja/mixes`, {
credentials: 'include',
}).then((response) => response.json())
console.log(mixData)
}
</script>
<div>
<h1>This would be a good place for listen history</h1>
<button on:click={testRequest} class="h-14 w-20 rounded-lg bg-lazuli-primary">Test Request</button>
</div>

View File

@@ -16,12 +16,14 @@
<h1>{albums.error}</h1> <h1>{albums.error}</h1>
{:else if $itemDisplayState === 'list'} {:else if $itemDisplayState === 'list'}
<div class="text-md flex flex-col gap-4"> <div class="text-md flex flex-col gap-4">
{#each albums as album} <!-- .slice is temporary to mimic performance with pagination -->
{#each albums.slice(0, 100) as album}
<ListItem mediaItem={album} /> <ListItem mediaItem={album} />
{/each} {/each}
</div> </div>
{:else} {:else}
<div id="library-wrapper"> <div id="library-wrapper">
<!-- .slice is temporary to mimic performance with pagination -->
{#each albums as album} {#each albums as album}
<AlbumCard {album} /> <AlbumCard {album} />
{/each} {/each}
@@ -36,4 +38,10 @@
/* gap: 1.5rem; */ /* gap: 1.5rem; */
grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr)); grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
} }
/* This caps the maxiumn number of columns at 10. Beyond that point the cards will continuously get larger */
@media (min-width: calc(13rem * 10)) {
#library-wrapper {
grid-template-columns: repeat(10, 1fr);
}
}
</style> </style>

View File

@@ -2,13 +2,12 @@
import LazyImage from '$lib/components/media/lazyImage.svelte' import LazyImage from '$lib/components/media/lazyImage.svelte'
import IconButton from '$lib/components/util/iconButton.svelte' import IconButton from '$lib/components/util/iconButton.svelte'
import ArtistList from '$lib/components/media/artistList.svelte' import ArtistList from '$lib/components/media/artistList.svelte'
import Services from '$lib/services.json'
import { goto } from '$app/navigation' import { goto } from '$app/navigation'
import { queue, newestAlert } from '$lib/stores' import { queue, newestAlert } from '$lib/stores'
export let album: Album export let album: Album
const queueRef = $queue // This nonsense is to prevent an bug that causes svelte to throw an error when setting a property of the queue directly
async function playAlbum() { async function playAlbum() {
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, { const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, {
credentials: 'include', credentials: 'include',
@@ -20,20 +19,21 @@
} }
const data = (await itemsResponse.json()) as { items: Song[] } const data = (await itemsResponse.json()) as { items: Song[] }
queueRef.setQueue(data.items) $queue.setQueue(data.items)
} }
</script> </script>
<div class="p-3"> <div class="overflow-hidden p-3">
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded-lg"> <div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded">
<button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}> <button id="thumbnail" class="h-full w-full" on:click={() => goto(`/details/album?id=${album.id}&connection=${album.connection.id}`)}>
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} /> <LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />
</button> </button>
<div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0"> <div id="play-button" class="absolute left-1/2 top-1/2 h-1/4 -translate-x-1/2 -translate-y-1/2 opacity-0 transition-opacity">
<IconButton halo={true} on:click={playAlbum}> <IconButton halo={true} on:click={playAlbum}>
<i slot="icon" class="fa-solid fa-play text-2xl" /> <i slot="icon" class="fa-solid fa-play text-2xl" />
</IconButton> </IconButton>
</div> </div>
<img id="connection-type-icon" class="absolute left-2 top-2 h-9 w-9 opacity-0 transition-opacity" src={Services[album.connection.type].icon} alt={Services[album.connection.type].displayName} />
</div> </div>
<div class="py-2 text-center text-sm"> <div class="py-2 text-center text-sm">
<div class="line-clamp-2">{album.name}</div> <div class="line-clamp-2">{album.name}</div>
@@ -45,15 +45,18 @@
<style> <style>
#thumbnail-wrapper:hover > #thumbnail { #thumbnail-wrapper:hover > #thumbnail {
filter: brightness(40%); filter: brightness(35%);
} }
#thumbnail-wrapper:hover > #play-button { #thumbnail-wrapper:hover > #play-button {
opacity: 100%; opacity: 1;
}
/* #connection-type-icon {
filter: grayscale();
} */
#thumbnail-wrapper:hover > #connection-type-icon {
opacity: 1;
} }
#thumbnail { #thumbnail {
transition: filter 150ms ease; transition: filter 150ms ease;
} }
#play-button {
transition: opacity 150ms ease;
}
</style> </style>

View File

@@ -27,7 +27,11 @@ export const actions: Actions = {
if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message }) if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message })
const newConnectionId = DB.addConnectionInfo({ userId: locals.user.id, type: 'jellyfin', service: { userId: authData.User.Id, serverUrl: serverUrl.toString() }, tokens: { accessToken: authData.AccessToken } }) const userId = locals.user.id
const serviceUserId = authData.User.Id
const accessToken = authData.AccessToken
const newConnectionId = await DB.connections.insert({ id: DB.uuid(), userId, type: 'jellyfin', serviceUserId, serverUrl: serverUrl.toString(), accessToken }, 'id').then((data) => data[0].id)
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`) const newConnection = await fetch(`/api/connections?id=${newConnectionId}`)
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>) .then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
@@ -39,18 +43,18 @@ export const actions: Actions = {
const formData = await request.formData() const formData = await request.formData()
const { code } = Object.fromEntries(formData) const { code } = Object.fromEntries(formData)
const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // ! DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD. const client = new google.auth.OAuth2({ clientId: PUBLIC_YOUTUBE_API_CLIENT_ID, clientSecret: YOUTUBE_API_CLIENT_SECRET, redirectUri: 'http://localhost:5173' }) // ! DO NOT SHIP THIS. THE CLIENT SECRET SHOULD NOT BE MADE AVAILABLE TO USERS. MAKE A REQUEST TO THE LAZULI WEBSITE INSTEAD.
const { tokens } = await client.getToken(code.toString()) const { access_token, refresh_token, expiry_date } = (await client.getToken(code.toString())).tokens
const youtube = google.youtube('v3') const youtube = google.youtube('v3')
const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: tokens.access_token! }) const userChannelResponse = await youtube.channels.list({ mine: true, part: ['id', 'snippet'], access_token: access_token! })
const userChannel = userChannelResponse.data.items![0] const userChannel = userChannelResponse.data.items![0]
const newConnectionId = DB.addConnectionInfo({ const userId = locals.user.id
userId: locals.user.id, const serviceUserId = userChannel.id!
type: 'youtube-music',
service: { userId: userChannel.id! }, const newConnectionId = await DB.connections
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! }, .insert({ id: DB.uuid(), userId, type: 'youtube-music', serviceUserId, accessToken: access_token!, refreshToken: refresh_token!, expiry: expiry_date! }, 'id')
}) .then((data) => data[0].id)
const newConnection = await fetch(`/api/connections?id=${newConnectionId}`) const newConnection = await fetch(`/api/connections?id=${newConnectionId}`)
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>) .then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
@@ -62,7 +66,7 @@ export const actions: Actions = {
const formData = await request.formData() const formData = await request.formData()
const connectionId = formData.get('connectionId')!.toString() const connectionId = formData.get('connectionId')!.toString()
DB.deleteConnectionInfo(connectionId) await DB.connections.where('id', connectionId).del()
return { deletedConnectionId: connectionId } return { deletedConnectionId: connectionId }
}, },

View File

@@ -1,12 +1,12 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url, request }) => { export const GET: RequestHandler = async ({ url, request }) => {
const connectionId = url.searchParams.get('connection') const connectionId = url.searchParams.get('connection')
const id = url.searchParams.get('id') const id = url.searchParams.get('id')
if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 }) if (!(connectionId && id)) return new Response('Missing query parameter', { status: 400 })
// Might want to re-evaluate how specific I make these ^ v error response messages // Might want to re-evaluate how specific I make these ^ v error response messages
const connection = Connections.getConnection(connectionId) const connection = await buildConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' }) const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' })

View File

@@ -1,22 +1,19 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',') const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
if (!ids) return new Response('Missing id query parameter', { status: 400 }) if (!ids) return new Response('Missing id query parameter', { status: 400 })
const connections = ( const connections = (await Promise.all(ids.map((id) => buildConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null)
await Promise.all(
ids.map((id) =>
Connections.getConnection(id)
?.getConnectionInfo()
.catch((reason) => {
console.error(`Failed to fetch connection info: ${reason}`)
return undefined
}),
),
)
).filter((connection): connection is ConnectionInfo => connection?.id !== undefined)
return Response.json({ connections }) const getConnectionInfo = (connection: Connection) =>
connection.getConnectionInfo().catch((reason) => {
console.error(`Failed to fetch connection info: ${reason}`)
return null
})
const connectionInfo = (await Promise.all(connections.map(getConnectionInfo))).filter((connectionInfo): connectionInfo is ConnectionInfo => connectionInfo !== null)
return Response.json({ connections: connectionInfo })
} }

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId! const connectionId = params.connectionId!
const connection = Connections.getConnection(connectionId) const connection = await buildConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const albumId = url.searchParams.get('id') const albumId = url.searchParams.get('id')

View File

@@ -1,12 +1,13 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const { connectionId, albumId } = params const { connectionId, albumId } = params
const connection = Connections.getConnection(connectionId!)
const connection = await buildConnection(connectionId!).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const items = await connection.getAlbumItems(albumId!).catch(() => undefined) const items = await connection.getAlbumItems(albumId!).catch(() => null)
if (!items) return new Response(`Failed to fetch album with id: ${albumId!}`, { status: 400 }) if (!items) return new Response(`Failed to fetch album with id: ${albumId!}`, { status: 400 })
return Response.json({ items }) return Response.json({ items })

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const connectionId = params.connectionId! const connectionId = params.connectionId!
const connection = Connections.getConnection(connectionId) const connection = await buildConnection(connectionId).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const playlistId = url.searchParams.get('id') const playlistId = url.searchParams.get('id')

View File

@@ -1,9 +1,9 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildConnection } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params, url }) => { export const GET: RequestHandler = async ({ params, url }) => {
const { connectionId, playlistId } = params const { connectionId, playlistId } = params
const connection = Connections.getConnection(connectionId!) const connection = await buildConnection(connectionId!).catch(() => null)
if (!connection) return new Response('Invalid connection id', { status: 400 }) if (!connection) return new Response('Invalid connection id', { status: 400 })
const startIndexString = url.searchParams.get('startIndex') const startIndexString = url.searchParams.get('startIndex')

View File

@@ -5,6 +5,56 @@ const MAX_YOUTUBE_THUMBNAIL_SCALAR_SIZE = 16383
// TODO: It is possible to get images through many paths in the jellyfin API. To add support for a path, add a regex for it // TODO: It is possible to get images through many paths in the jellyfin API. To add support for a path, add a regex for it
const jellyfinImagePathnames = [/^\/Items\/([0-9a-f]{32})\/Images\/(Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter|BoxRear|Profile)$/] const jellyfinImagePathnames = [/^\/Items\/([0-9a-f]{32})\/Images\/(Primary|Art|Backdrop|Banner|Logo|Thumb|Disc|Box|Screenshot|Menu|Chapter|BoxRear|Profile)$/]
// * Notes for the future:
// Spotify does not appear to use query parameter to scale its iamges and instead scale them via slight variations in the URL:
//
// Demon's Jingles - 300x300 - https://i.scdn.co/image/ab67616d00001e0230d02cfb02d41c65f0259c49
// Red Heart - 300x300 - https://i.scdn.co/image/ab67616d00001e02c1b377b71713519ac06d8025
// Demon's Jingles - 64x64 - https://i.scdn.co/image/ab67616d0000485130d02cfb02d41c65f0259c49
// Red Heart - 64x64 - https://i.scdn.co/image/ab67616d00004851c1b377b71713519ac06d8025
//
// From what I can tell the first 7 of the 40 hex characters are always the same. The next five seem to be based on what kind of media
// the image is asscoiated with.
//
// Type | Song | Artist |
// Code | d0000 | 10000 |
//
// Then there are four more characters which appear to be a size code. However size codes do no appear work across different media types.
//
// Size | 64x64 | 160x160 | 300x300 | 320x320 | 640x640 | 640x640 |
// Code | 4851 | f178 | 1e02 | 5174 | b273 | e5eb |
// Type | Song | Artist | Song | Artist | Song | Artist |
//
// It's also worth noting that while I have been using the word 'Song' spotify doesn't actually appear to have unique images for songs and
// from what I can tell all Song images are actually just the image of the album the song is from. In the case of singles, those are really
// just 1-length albums as far as Spotify is concerned. So consider Songs and Albums the same when it comes to Spotify images
//
// Playlists are pretty interesting, here's a sample of a playlist image:
//
// J-Core Mix - 60x60 - https://mosaic.scdn.co/60/ab67616d00001e021ad3e724a80ccbd585df8ea6ab67616d00001e029056aaf4675ec39d04b38c6dab67616d00001e02b204764ed7641264c954afa4ab67616d00001e02c1b377b71713519ac06d8025
//
// There appear to be three sizes of playlist thumbnail as well, 60x60, 300x300, and 640x640. However this time the dimension is embeded directly pathname.
// The much longer hex code in the pathname is actually just the four codes for the thumbnails that show up in the mosaic concatenated together using the 300x300 code for each.
// What's even cooler is this endpoint can generate them on the fly, meaning you just stick four hex strings in and it will generate the mosaic.
// You have to put in four though, two, three, and anything greater than four will simply return a bad request, and one will simply return the image you specified
// but at the size specfied in the image code. You can however use whatever image size code in the hex strings though and it will generate the mosaic using whaterver
// resolution passed, it just doesn't make any sense to use the 640x640 code since the grid is a 2x2 with a maximum resolution of 640x640 anyway, so just use 300x300.
//
// The only question I have left is, why? Between YouTube and Spotify I really question the API design of some of these multi-billion dollar companies.
// InnerTube API response are just abominable, who the fuck describes the structure of their UI by wrapping the actually useful data in layers of completely
// abstract objcts and arrays like it's fucking HTML. I should never have to traverse 20+ layers deep into nonsense objects like musicResponsiveListItemFlexColumnRenderer
// just to get a name. At least Spotify has a well designed and developer friendly API structure, but seriously, why do all of the size code nonsense. If you're not
// going to support formats like webm and only want to stick to static images, that's fine, but just make the path /image/{itemId} and then you can
// specify what size you need with a query parameter ?size=small|medium|large. That way if you ever do want to move to a model that can support dynamically generating images
// of a specific size with query params, your API is already partially the way there. I won't complain about the playlist image generator though, that's pretty cool.
// My only suggestions would be to get rid of the image code nonsense and just use the song/album ids and also make both the ids and size a query param, not part of the path.
// YouTube Music does support dynamically resizing images in the API which is nice, except for the fact that they do it in the stupidest fucking way I have ever seen.
// What the fuck is this: =w1000&h1000. Those are not what query params look like, why would you bother making this fake query param bullshit when what you are trying to do has
// been a standard part URLs since their inception. Also you pull your images from SIX DIFFERENT FUCKING ORIGINS, only four of which actually support image scaling.
// In both YouTube Music and Spotify none of these image endpoints are protected in any way, so why do you inist on pissing off me and probably your own developers with these asinine practices?
//
// It's not perfect, but compared to this bullshit, the Jellyfin API is really fucking good.
function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?: number }): string | null { function modifyImageURL(imageURL: URL, options?: { maxWidth?: number; maxHeight?: number }): string | null {
const maxWidth = options?.maxWidth const maxWidth = options?.maxWidth
const maxHeight = options?.maxHeight const maxHeight = options?.maxHeight

View File

@@ -1,28 +1,23 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ url }) => { export const GET: RequestHandler = async ({ url }) => {
const { query, userId, filter } = Object.fromEntries(url.searchParams) as { [k: string]: string | undefined } const { query, userId, filter } = Object.fromEntries(url.searchParams) as { [k: string]: string | undefined }
if (!(query && userId)) return new Response('Missing search parameter', { status: 400 }) if (!(query && userId)) return new Response('Missing search parameter', { status: 400 })
const userConnections = Connections.getUserConnections(userId) const userConnections = await buildUserConnections(userId).catch(() => null)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
let checkedFilter: 'song' | 'album' | 'artist' | 'playlist' | undefined let checkedFilter: 'song' | 'album' | 'artist' | 'playlist' | undefined
if (filter === 'song' || filter === 'album' || filter === 'artist' || filter === 'playlist') checkedFilter = filter if (filter === 'song' || filter === 'album' || filter === 'artist' || filter === 'playlist') checkedFilter = filter
const searchResults = ( const search = (connection: Connection) =>
await Promise.all(
userConnections.map((connection) =>
connection.search(query, checkedFilter).catch((reason) => { connection.search(query, checkedFilter).catch((reason) => {
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`) console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
return undefined return null
}), })
),
) const searchResults = (await Promise.all(userConnections.map(search))).flat().filter((result): result is Song | Album | Artist | Playlist => result !== null)
)
.flat()
.filter((result): result is Song | Album | Artist | Playlist => result?.id !== undefined)
return Response.json({ searchResults }) return Response.json({ searchResults })
} }

View File

@@ -1,22 +1,17 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userConnections = await buildUserConnections(params.userId!).catch(() => null)
const userConnections = Connections.getUserConnections(userId)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const connections = ( const getConnectionInfo = (connection: Connection) =>
await Promise.all(
userConnections.map((connection) =>
connection.getConnectionInfo().catch((reason) => { connection.getConnectionInfo().catch((reason) => {
console.log(`Failed to fetch connection info: ${reason}`) console.log(`Failed to fetch connection info: ${reason}`)
return undefined return null
}), })
),
) const connections = (await Promise.all(userConnections.map(getConnectionInfo))).filter((info): info is ConnectionInfo => info !== null)
).filter((info): info is ConnectionInfo => info !== undefined)
return Response.json({ connections }) return Response.json({ connections })
} }

View File

@@ -1,10 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userConnections = await buildUserConnections(params.userId!).catch(() => null)
const userConnections = Connections.getUserConnections(userId)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.albums()))).flat() const items = (await Promise.all(userConnections.map((connection) => connection.library.albums()))).flat()

View File

@@ -1,10 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userConnections = await buildUserConnections(params.userId!).catch(() => null)
const userConnections = Connections.getUserConnections(userId)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.artists()))).flat() const items = (await Promise.all(userConnections.map((connection) => connection.library.artists()))).flat()

View File

@@ -1,10 +1,8 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildUserConnections } from '$lib/server/api-helper'
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userConnections = await buildUserConnections(params.userId!).catch(() => null)
const userConnections = Connections.getUserConnections(userId)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const items = (await Promise.all(userConnections.map((connection) => connection.library.playlists()))).flat() const items = (await Promise.all(userConnections.map((connection) => connection.library.playlists()))).flat()

View File

@@ -1,26 +1,19 @@
import type { RequestHandler } from '@sveltejs/kit' import type { RequestHandler } from '@sveltejs/kit'
import { Connections } from '$lib/server/connections' import { buildUserConnections } from '$lib/server/api-helper'
// This is temporary functionally for the sake of developing the app. // This is temporary functionally for the sake of developing the app.
// In the future will implement more robust algorithm for offering recommendations // In the future will implement more robust algorithm for offering recommendations
export const GET: RequestHandler = async ({ params }) => { export const GET: RequestHandler = async ({ params }) => {
const userId = params.userId! const userConnections = await buildUserConnections(params.userId!).catch(() => null)
const userConnections = Connections.getUserConnections(userId)
if (!userConnections) return new Response('Invalid user id', { status: 400 }) if (!userConnections) return new Response('Invalid user id', { status: 400 })
const recommendations = ( const getRecommendations = (connection: Connection) =>
await Promise.all(
userConnections.map((connection) =>
connection.getRecommendations().catch((reason) => { connection.getRecommendations().catch((reason) => {
console.log(`Failed to fetch recommendations: ${reason}`) console.log(`Failed to fetch recommendations: ${reason}`)
return undefined return null
}), })
),
) const recommendations = (await Promise.all(userConnections.map(getRecommendations))).flat().filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
)
.flat()
.filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
return Response.json({ recommendations }) return Response.json({ recommendations })
} }

View File

@@ -0,0 +1,41 @@
import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
import { DB } from '$lib/server/db'
// * Hook middleware garruntees mixId is valid.
// * Will intercept the call if the mixId does not exist
export const GET: RequestHandler = async ({ params }) => {
const mix = (await DB.mixes.where('id', params.mixId!).first())!
return Response.json(mix satisfies Mix)
}
const mixUpdate = z.object({
name: z.string().optional(),
thumbnailTag: z.string().optional(),
description: z.string().optional(),
})
const updatedMixResponse = new Response('Updated mix.', { status: 200 })
const invalidDataResponse = new Response('Invalid Mix Data', { status: 400 })
export const PATCH: RequestHandler = async ({ params, request }) => {
const updateMixData = await request
.json()
.then((data) => mixUpdate.parse(data))
.catch(() => null)
if (!updateMixData) return invalidDataResponse
const mixId = params.mixId!
const { name, thumbnailTag, description } = updateMixData
await DB.mixes.where('id', mixId).update({ name, thumbnailTag, description })
return updatedMixResponse
}
const deletedMixResponse = new Response('Deleted mix.', { status: 200 })
export const DELETE: RequestHandler = async ({ params }) => {
await DB.mixes.where('id', params.mixId!).del()
return deletedMixResponse
}

View File

@@ -0,0 +1,28 @@
import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
import { DB } from '$lib/server/db'
const isPositiveInteger = (n: number) => !Number.isNaN(n) && Number.isSafeInteger(n) && n > 0
// export const GET: RequestHandler = async ({ params, url }) => {
// const mixId = params.mixId!
// const startIndexQuery = Number(url.searchParams.get('startIndex'))
// const startIndex = isPositiveInteger(startIndexQuery) ? startIndexQuery : 0
// const limitQuery = Number(url.searchParams.get('limit'))
// const limit = isPositiveInteger(limitQuery) ? limitQuery : Number.POSITIVE_INFINITY
// const playlistItemIds = await DB.mixItems
// .select('*')
// .where('id', mixId)
// .whereBetween('index', [startIndex, startIndex + limit - 1])
// return Response.json()
// }
// export const POST: RequestHandler = async ({ params }) => {}
// export const PATCH: RequestHandler = async ({ params }) => {}
// export const DELETE: RequestHandler = async ({ params }) => {}

View File

@@ -0,0 +1,30 @@
import type { RequestHandler } from '@sveltejs/kit'
import { z } from 'zod'
import { DB } from '$lib/server/db'
export const GET: RequestHandler = async ({ params }) => {
const mix = await DB.mixes.where('userId', params.userId!).select('*')
return Response.json(mix satisfies Mix[])
}
const newMix = z.object({
name: z.string(),
thumbnailTag: z.string().optional(),
description: z.string().optional(),
})
const invalidDataResponse = new Response('Invalid Mix Data', { status: 400 })
export const POST: RequestHandler = async ({ params, request }) => {
const mixData = await request
.json()
.then((data) => newMix.parse(data))
.catch(() => null)
if (!mixData) return invalidDataResponse
const userId = params.userId!
const { name, thumbnailTag, description } = mixData
const id = await DB.mixes.insert({ id: DB.uuid(), userId, name, thumbnailTag, description, trackCount: 0, duration: 0 }, 'id')
return Response.json({ id }, { status: 201 })
}

View File

@@ -15,7 +15,7 @@ export const actions: Actions = {
const formData = await request.formData() const formData = await request.formData()
const { username, password, redirectLocation } = Object.fromEntries(formData) const { username, password, redirectLocation } = Object.fromEntries(formData)
const user = DB.getUsername(username.toString()) const user = await DB.users.where('username', username.toString()).first()
if (!user) return fail(400, { message: 'Invalid Username' }) if (!user) return fail(400, { message: 'Invalid Username' })
const passwordValid = await compare(password.toString(), user.passwordHash) const passwordValid = await compare(password.toString(), user.passwordHash)
@@ -34,8 +34,20 @@ export const actions: Actions = {
const { username, password } = Object.fromEntries(formData) const { username, password } = Object.fromEntries(formData)
const passwordHash = await hash(password.toString(), 10) const passwordHash = await hash(password.toString(), 10)
const newUser = DB.addUser(username.toString(), passwordHash) const newUser = await DB.users
if (!newUser) return fail(400, { message: 'Username already in use' }) .insert({ id: DB.uuid(), username: username.toString(), passwordHash }, '*')
.then((data) => data[0])
.catch((error: InstanceType<typeof DB.sqliteError>) => error)
if (newUser instanceof DB.sqliteError) {
switch (newUser.code) {
case 'SQLITE_CONSTRAINT_UNIQUE':
return fail(400, { message: 'Username already in use' })
default:
console.log(newUser)
return fail(500, { message: 'Failed to create user. Reason Unknown' })
}
}
const authToken = jwt.sign({ id: newUser.id, username: newUser.username }, SECRET_JWT_KEY, { expiresIn: '100d' }) const authToken = jwt.sign({ id: newUser.id, username: newUser.username }, SECRET_JWT_KEY, { expiresIn: '100d' })