Database overhall with knex.js, some things untested
This commit is contained in:
608
package-lock.json
generated
608
package-lock.json
generated
@@ -9,14 +9,15 @@
|
||||
"version": "0.0.1",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"bcrypt-ts": "^5.0.1",
|
||||
"better-sqlite3": "^9.3.0",
|
||||
"fast-average-color": "^9.4.0",
|
||||
"googleapis": "^133.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"musicbrainz-api": "^0.15.0"
|
||||
"knex": "^3.1.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-auto": "^3.0.0",
|
||||
@@ -748,17 +749,6 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz",
|
||||
@@ -842,30 +832,14 @@
|
||||
"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": {
|
||||
"version": "7.6.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.8.tgz",
|
||||
"integrity": "sha512-ASndM4rdGrzk7iXXqyNC4fbwt4UEjpK0i3j4q4FyeQrLAthfB6s7EF135ZJE0qQxtKIMFwmyT6x0switET7uIw==",
|
||||
"version": "7.6.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.10.tgz",
|
||||
"integrity": "sha512-TZBjD+yOsyrUJGmcUj6OS3JADk3+UZcNv3NOBqGkM09bZdi28fNZw8ODqbMOLfKCu7RYCO62/ldq1iHbzxqoPw==",
|
||||
"dependencies": {
|
||||
"@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": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
@@ -878,11 +852,6 @@
|
||||
"integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==",
|
||||
"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": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz",
|
||||
@@ -905,35 +874,6 @@
|
||||
"integrity": "sha512-Sk/uYFOBAB7mb74XcpizmH0KOR2Pv3D2Hmrh1Dmy5BmK3MpdSa5kqZcg6EKBdklU0bFXX9gCfzvpnyUehrPIuA==",
|
||||
"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": {
|
||||
"version": "8.11.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||
@@ -1015,11 +955,6 @@
|
||||
"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": {
|
||||
"version": "10.4.17",
|
||||
"resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz",
|
||||
@@ -1155,12 +1090,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz",
|
||||
"integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==",
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"fill-range": "^7.0.1"
|
||||
"fill-range": "^7.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
@@ -1235,58 +1170,6 @@
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"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": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz",
|
||||
@@ -1324,9 +1207,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001579",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz",
|
||||
"integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==",
|
||||
"version": "1.0.30001636",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz",
|
||||
"integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "3.5.3",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz",
|
||||
@@ -1411,16 +1289,10 @@
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
|
||||
"dependencies": {
|
||||
"delayed-stream": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.19",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz",
|
||||
"integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ=="
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "4.1.1",
|
||||
@@ -1532,14 +1404,6 @@
|
||||
"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": {
|
||||
"version": "1.1.4",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
||||
@@ -1709,7 +1565,14 @@
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
|
||||
"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": {
|
||||
"node": ">=6"
|
||||
}
|
||||
@@ -1781,9 +1644,9 @@
|
||||
"integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz",
|
||||
"integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==",
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"to-regex-range": "^5.0.1"
|
||||
@@ -1808,27 +1671,6 @@
|
||||
"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": {
|
||||
"version": "4.3.7",
|
||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
|
||||
@@ -1919,17 +1761,19 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-stream": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-8.0.1.tgz",
|
||||
"integrity": "sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==",
|
||||
"node_modules/get-package-type": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
|
||||
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "0.0.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
@@ -2158,28 +1978,6 @@
|
||||
"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": {
|
||||
"version": "7.0.4",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2273,7 +2079,6 @@
|
||||
"version": "2.13.1",
|
||||
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz",
|
||||
"integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"hasown": "^2.0.0"
|
||||
},
|
||||
@@ -2381,24 +2186,6 @@
|
||||
"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": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz",
|
||||
@@ -2439,14 +2226,6 @@
|
||||
"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": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -2456,6 +2235,72 @@
|
||||
"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": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz",
|
||||
@@ -2477,6 +2322,11 @@
|
||||
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
|
||||
"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": {
|
||||
"version": "4.3.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz",
|
||||
@@ -2572,25 +2411,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": {
|
||||
"version": "3.1.0",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
|
||||
@@ -2791,17 +2588,6 @@
|
||||
"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": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
@@ -2836,14 +2622,6 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -2877,8 +2655,7 @@
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
|
||||
"dev": true
|
||||
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="
|
||||
},
|
||||
"node_modules/path-scurry": {
|
||||
"version": "1.10.1",
|
||||
@@ -2907,6 +2684,11 @@
|
||||
"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": {
|
||||
"version": "1.0.0",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz",
|
||||
@@ -3236,11 +3013,6 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"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": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
|
||||
@@ -3328,16 +3081,21 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ=="
|
||||
"node_modules/rechoir": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz",
|
||||
"integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==",
|
||||
"dependencies": {
|
||||
"resolve": "^1.20.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.8",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz",
|
||||
"integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"is-core-module": "^2.13.0",
|
||||
"path-parse": "^1.0.7",
|
||||
@@ -3350,11 +3108,6 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
|
||||
@@ -3364,20 +3117,6 @@
|
||||
"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": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz",
|
||||
@@ -3669,14 +3408,6 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
|
||||
@@ -3686,15 +3417,6 @@
|
||||
"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": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||
@@ -3891,7 +3613,6 @@
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
|
||||
"integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
@@ -4096,6 +3817,14 @@
|
||||
"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": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -4117,6 +3846,14 @@
|
||||
"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": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
|
||||
@@ -4148,28 +3885,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": {
|
||||
"version": "0.0.3",
|
||||
"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",
|
||||
"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": {
|
||||
"version": "1.0.13",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz",
|
||||
@@ -4254,15 +3961,6 @@
|
||||
"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": {
|
||||
"version": "2.0.8",
|
||||
"resolved": "https://registry.npmjs.org/url-template/-/url-template-2.0.8.tgz",
|
||||
@@ -4492,6 +4190,14 @@
|
||||
"engines": {
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,13 +28,14 @@
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^6.5.1",
|
||||
"@types/better-sqlite3": "^7.6.8",
|
||||
"@types/better-sqlite3": "^7.6.10",
|
||||
"@types/jsonwebtoken": "^9.0.5",
|
||||
"bcrypt-ts": "^5.0.1",
|
||||
"better-sqlite3": "^9.3.0",
|
||||
"fast-average-color": "^9.4.0",
|
||||
"googleapis": "^133.0.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"musicbrainz-api": "^0.15.0"
|
||||
"knex": "^3.1.0",
|
||||
"zod": "^3.23.8"
|
||||
}
|
||||
}
|
||||
|
||||
82
src/app.d.ts
vendored
82
src/app.d.ts
vendored
@@ -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.
|
||||
// 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 = {
|
||||
id: string
|
||||
username: string
|
||||
@@ -59,57 +66,61 @@ declare global {
|
||||
public readonly id: string
|
||||
|
||||
/** Retireves general information about the connection */
|
||||
getConnectionInfo: () => Promise<ConnectionInfo>
|
||||
getConnectionInfo(): Promise<ConnectionInfo>
|
||||
|
||||
/** 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 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
|
||||
* @param {string} searchTerm The string of text to query
|
||||
* @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 {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 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
|
||||
* @param {string} id The id of the requested song
|
||||
* @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 {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.
|
||||
*/
|
||||
getAudioStream: (id: string, headers: Headers) => Promise<Response>
|
||||
getAudioStream(id: string, headers: Headers): Promise<Response>
|
||||
|
||||
/**
|
||||
* @param id The id of an album
|
||||
* @returns A promise of the album as an Album object
|
||||
* @param {string} id The id of an album
|
||||
* @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
|
||||
* @returns A promise of the songs in the album as and array of Song objects
|
||||
* @param {string} id The id of an album
|
||||
* @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
|
||||
* @returns A promise of the playlist of as a Playlist object
|
||||
* @param {string} id The id of a playlist
|
||||
* @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 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
|
||||
* @returns A promise of the songs in the playlist as and array of Song objects
|
||||
* @param {string} id The id of a playlist
|
||||
* @param {number} startIndex The index to start at (0 based). All playlist items with a lower index will be dropped from the results
|
||||
* @param {number} limit The maximum number of playlist items to return
|
||||
* @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: {
|
||||
albums: () => Promise<Album[]>
|
||||
artists: () => Promise<Artist[]>
|
||||
playlists: () => Promise<Playlist[]>
|
||||
albums(): Promise<Album[]>
|
||||
artists(): Promise<Artist[]>
|
||||
playlists(): Promise<Playlist[]>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +134,7 @@ declare global {
|
||||
type Song = {
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
type: ConnectionType
|
||||
}
|
||||
id: string
|
||||
name: string
|
||||
@@ -150,7 +161,7 @@ declare global {
|
||||
type Album = {
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
type: ConnectionType
|
||||
}
|
||||
id: string
|
||||
name: string
|
||||
@@ -167,7 +178,7 @@ declare global {
|
||||
type Artist = {
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
type: ConnectionType
|
||||
}
|
||||
id: 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
|
||||
connection: {
|
||||
id: string
|
||||
type: 'jellyfin' | 'youtube-music'
|
||||
type: ConnectionType
|
||||
}
|
||||
id: 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> };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { userExists, mixExists } from '$lib/server/api-helper'
|
||||
import jwt from 'jsonwebtoken'
|
||||
|
||||
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 }) => {
|
||||
const urlpath = event.url.pathname
|
||||
|
||||
if (urlpath.startsWith('/login')) return resolve(event)
|
||||
|
||||
if (urlpath.startsWith('/api')) {
|
||||
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 })
|
||||
}
|
||||
if (urlpath.startsWith('/api')) return handleAPIRequest({ event, resolve })
|
||||
|
||||
const authToken = event.cookies.get('lazuli-auth')
|
||||
if (!authToken) throw redirect(303, `/login?redirect=${urlpath}`)
|
||||
@@ -39,9 +52,3 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
|
||||
return resolve(event)
|
||||
}
|
||||
|
||||
export const handleFetch: HandleFetch = async ({ event, request, fetch }) => {
|
||||
const authorized = verifyAuthToken(event)
|
||||
|
||||
return authorized ? fetch(request) : new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
@@ -12,18 +12,16 @@
|
||||
export let linked = true
|
||||
</script>
|
||||
|
||||
<div class="break-keep">
|
||||
<div class="break-words break-keep">
|
||||
{#if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists === 'string'}
|
||||
{mediaItem.artists}
|
||||
{:else if 'artists' in mediaItem && mediaItem.artists && typeof mediaItem.artists !== 'string' && mediaItem.artists.length > 0}
|
||||
{#each mediaItem.artists as artist, index}
|
||||
{@const needsComma = index < mediaItem.artists.length - 1}
|
||||
{#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}
|
||||
<span>{artist.name}</span>
|
||||
{/if}
|
||||
{#if index < mediaItem.artists.length - 1}
|
||||
<span style="margin-left: -0.25em; margin-right: 0.25em">,</span>
|
||||
<span class:needsComma class="artist-name">{artist.name}</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else if 'uploader' in mediaItem && mediaItem.uploader}
|
||||
@@ -40,3 +38,10 @@
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.needsComma::after {
|
||||
content: ',';
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,11 +22,12 @@
|
||||
|
||||
let imageContainer: HTMLDivElement
|
||||
|
||||
// TODO: Implement auto-resizing
|
||||
function updateImage(newThumbnailURL: string) {
|
||||
if (!imageContainer) return
|
||||
|
||||
const width = imageContainer.clientWidth
|
||||
const height = imageContainer.clientHeight
|
||||
const width = imageContainer.clientWidth * 1.5 // 1.5x is a good compromise between sharpness and performance
|
||||
const height = imageContainer.clientHeight * 1.5
|
||||
|
||||
const newImage = new Image(width, height)
|
||||
imageContainer.appendChild(newImage)
|
||||
@@ -57,8 +58,8 @@
|
||||
}
|
||||
|
||||
newImage.onerror = () => {
|
||||
removeOldImage()
|
||||
newImage.style.opacity = '1'
|
||||
console.error(`Image from url: ${newThumbnailURL} failed to update`)
|
||||
imageContainer.removeChild(newImage)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
import ScrollingText from '$lib/components/util/scrollingText.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
|
||||
|
||||
let expanded = false
|
||||
@@ -45,15 +49,15 @@
|
||||
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
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,
|
||||
artwork: [
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96', type: 'image/png' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128', type: 'image/png' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192', type: 'image/png' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256', type: 'image/png' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384', type: 'image/png' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512', type: 'image/png' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=96`, sizes: '96x96' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=128`, sizes: '128x128' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=192`, sizes: '192x192' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=256`, sizes: '256x256' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=384`, sizes: '384x384' },
|
||||
{ src: `/api/remoteImage?url=${media.thumbnailUrl}&maxWidth=512`, sizes: '512x512' },
|
||||
],
|
||||
})
|
||||
}
|
||||
@@ -103,7 +107,7 @@
|
||||
{#if !expanded}
|
||||
<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">
|
||||
<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'} />
|
||||
</div>
|
||||
<section class="flex flex-grow flex-col justify-center gap-1">
|
||||
@@ -121,16 +125,14 @@
|
||||
<IconButton on:click={() => $queue.previous()}>
|
||||
<i slot="icon" class="fa-solid fa-backward-step text-xl" />
|
||||
</IconButton>
|
||||
<div class="aspect-square h-full rounded-full border border-neutral-700">
|
||||
<IconButton on:click={() => (paused = !paused)}>
|
||||
<div slot="icon">
|
||||
<div class="relative aspect-square h-full rounded-full border border-neutral-700">
|
||||
{#if waiting}
|
||||
<Loader size={1.5} />
|
||||
{:else}
|
||||
<i class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
||||
{/if}
|
||||
</div>
|
||||
<IconButton on:click={() => (paused = !paused)}>
|
||||
<i slot="icon" class="fa-solid {paused ? 'fa-play' : 'fa-pause'}" />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
<IconButton on:click={() => $queue.clear()}>
|
||||
<i slot="icon" class="fa-solid fa-stop text-xl" />
|
||||
@@ -156,7 +158,10 @@
|
||||
</div>
|
||||
</section>
|
||||
<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
|
||||
bind:value={volume}
|
||||
max={maxVolume}
|
||||
@@ -164,9 +169,6 @@
|
||||
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>
|
||||
<IconButton on:click={() => (shuffled ? $queue.reorder() : $queue.shuffle())}>
|
||||
<i slot="icon" class="fa-solid fa-shuffle {shuffled ? 'text-lazuli-primary' : 'text-white'}" />
|
||||
@@ -213,7 +215,7 @@
|
||||
</section>
|
||||
</section>
|
||||
<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" />
|
||||
<Slider
|
||||
bind:this={expandedProgressBar}
|
||||
@@ -324,7 +326,7 @@
|
||||
}
|
||||
#expanded-player {
|
||||
display: grid;
|
||||
grid-template-rows: calc(100% - 12rem) 12rem;
|
||||
grid-template-rows: calc(100% - 11rem) 11rem;
|
||||
}
|
||||
#song-queue-wrapper {
|
||||
display: grid;
|
||||
|
||||
@@ -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>
|
||||
42
src/lib/components/util/mixTab.svelte
Normal file
42
src/lib/components/util/mixTab.svelte
Normal 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>
|
||||
39
src/lib/components/util/navTab.svelte
Normal file
39
src/lib/components/util/navTab.svelte
Normal 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>
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
<div
|
||||
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)"
|
||||
role="slider"
|
||||
tabindex="0"
|
||||
@@ -39,7 +39,7 @@
|
||||
on:input={(event) => dispatch('seeking', { value: event.currentTarget.value })}
|
||||
on:change={(event) => dispatch('seeked', { value: event.currentTarget.value })}
|
||||
type="range"
|
||||
class="absolute z-10 h-1.5 w-full"
|
||||
class="absolute z-10 h-1 w-full"
|
||||
step="any"
|
||||
min="0"
|
||||
{max}
|
||||
@@ -48,7 +48,7 @@
|
||||
aria-hidden="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" />
|
||||
</div>
|
||||
|
||||
|
||||
48
src/lib/server/api-helper.ts
Normal file
48
src/lib/server/api-helper.ts
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -1,132 +1,187 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import type { Database as Sqlite3DB } from 'better-sqlite3'
|
||||
import { generateUUID } from '$lib/utils'
|
||||
import knex from 'knex'
|
||||
import { SqliteError } from 'better-sqlite3'
|
||||
|
||||
interface DBConnectionsTableSchema {
|
||||
id: string
|
||||
userId: string
|
||||
type: string
|
||||
service?: string
|
||||
tokens?: string
|
||||
}
|
||||
const connectionTypes = ['jellyfin', 'youtube-music']
|
||||
|
||||
export type ConnectionRow = {
|
||||
export declare namespace DBSchemas {
|
||||
interface Users {
|
||||
id: string
|
||||
userId: string
|
||||
} & (
|
||||
| {
|
||||
type: 'jellyfin'
|
||||
service: {
|
||||
userId: string
|
||||
serverUrl: string
|
||||
username: string
|
||||
passwordHash: string
|
||||
}
|
||||
tokens: {
|
||||
|
||||
interface JellyfinConnection {
|
||||
id: string
|
||||
userId: string
|
||||
type: 'jellyfin'
|
||||
serviceUserId: string
|
||||
serverUrl: string
|
||||
accessToken: string
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: 'youtube-music'
|
||||
service: {
|
||||
|
||||
interface YouTubeMusicConnection {
|
||||
id: string
|
||||
userId: string
|
||||
}
|
||||
tokens: {
|
||||
type: 'youtube-music'
|
||||
serviceUserId: string
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
expiry: number
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
class Storage {
|
||||
private readonly database: Sqlite3DB
|
||||
type Connections = JellyfinConnection | YouTubeMusicConnection
|
||||
|
||||
constructor(database: Sqlite3DB) {
|
||||
this.database = database
|
||||
this.database.pragma('foreign_keys = ON')
|
||||
this.database.exec(`CREATE TABLE IF NOT EXISTS Users(
|
||||
id VARCHAR(36) PRIMARY KEY,
|
||||
username VARCHAR(30) UNIQUE NOT NULL,
|
||||
passwordHash VARCHAR(72) NOT NULL
|
||||
)`)
|
||||
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)
|
||||
)`)
|
||||
interface Mixes {
|
||||
id: string
|
||||
userId: string
|
||||
name: string
|
||||
thumbnailTag?: string
|
||||
description?: string
|
||||
trackCount: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
public getUser = (id: string): User | undefined => {
|
||||
const user = this.database.prepare(`SELECT * FROM Users WHERE id = ? LIMIT 1`).get(id) as User | undefined
|
||||
return user
|
||||
interface MixItems {
|
||||
mixId: string
|
||||
connectionId: string
|
||||
connectionType: ConnectionType
|
||||
id: string
|
||||
index: number
|
||||
}
|
||||
|
||||
public getUsername = (username: string): User | undefined => {
|
||||
const user = this.database.prepare(`SELECT * FROM Users WHERE lower(username) = ? LIMIT 1`).get(username.toLowerCase()) as User | undefined
|
||||
return user
|
||||
interface Songs {
|
||||
connectionId: string
|
||||
connectionType: ConnectionType
|
||||
id: string
|
||||
name: string
|
||||
duration: number
|
||||
thumbnailUrl: string
|
||||
releaseDate?: string
|
||||
artists?: {
|
||||
id: string
|
||||
name: string
|
||||
}[]
|
||||
album?: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
public addUser = (username: string, passwordHash: string): User => {
|
||||
const userId = generateUUID()
|
||||
this.database.prepare(`INSERT INTO Users(id, username, passwordHash) VALUES(?, ?, ?)`).run(userId, username, passwordHash)
|
||||
return this.getUser(userId)!
|
||||
uploader?: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
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')
|
||||
isVideo: boolean
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
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 { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private'
|
||||
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 =
|
||||
| {
|
||||
@@ -55,7 +55,10 @@ export class YouTubeMusic implements Connection {
|
||||
}
|
||||
|
||||
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
|
||||
if (access_token) {
|
||||
@@ -515,6 +518,43 @@ export class YouTubeMusic implements Connection {
|
||||
}
|
||||
}) 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 {
|
||||
@@ -742,8 +782,8 @@ class YTRequestManager {
|
||||
if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest
|
||||
|
||||
this.accessTokenRefreshRequest = refreshAccessToken()
|
||||
.then(({ accessToken, expiry }) => {
|
||||
DB.updateTokens(this.connectionId, { accessToken, refreshToken: this.refreshToken, expiry })
|
||||
.then(async ({ accessToken, expiry }) => {
|
||||
await DB.connections.where('id', this.connectionId).update('tokens', { accessToken, refreshToken: this.refreshToken, expiry })
|
||||
this.currentAccessToken = accessToken
|
||||
this.expiry = expiry
|
||||
this.accessTokenRefreshRequest = null
|
||||
|
||||
@@ -1,14 +1,32 @@
|
||||
<script lang="ts">
|
||||
import SearchBar from '$lib/components/util/searchBar.svelte'
|
||||
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 { goto } from '$app/navigation'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
|
||||
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
|
||||
|
||||
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>
|
||||
|
||||
<main id="grid-wrapper" class="h-full">
|
||||
@@ -24,10 +42,23 @@
|
||||
</IconButton>
|
||||
</div>
|
||||
</nav>
|
||||
<section id="sidebar" class="pt-4 font-light">
|
||||
<NavTab label={'Home'} icon={'fa-solid fa-wave-square'} redirect={'/'} disabled={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)} />
|
||||
<section id="sidebar" class="relative pt-4 text-sm font-normal">
|
||||
<div class="mb-10">
|
||||
<NavTab label="Home" icon="fa-solid fa-wave-square" redirect="/" disabled={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 id="content-wrapper" class="no-scrollbar overflow-x-clip overflow-y-scroll pr-8">
|
||||
<slot />
|
||||
@@ -39,8 +70,8 @@
|
||||
#grid-wrapper,
|
||||
#navbar {
|
||||
display: grid;
|
||||
column-gap: 1rem;
|
||||
grid-template-columns: 14rem auto 14rem;
|
||||
column-gap: 3rem;
|
||||
grid-template-columns: 12rem auto 12rem;
|
||||
}
|
||||
|
||||
#grid-wrapper {
|
||||
@@ -54,7 +85,4 @@
|
||||
#sidebar {
|
||||
grid-area: 2 / 1 / 3 / 2;
|
||||
}
|
||||
#content-wrapper {
|
||||
grid-area: 2 / 2 / 3 / 4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
$: currentPathname = data.url.pathname
|
||||
</script>
|
||||
|
||||
<main class="py-8">
|
||||
<main class="py-4">
|
||||
<nav id="nav-options" class="mb-8 flex h-12 justify-between">
|
||||
<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>
|
||||
|
||||
5
src/routes/(app)/library/+page.server.ts
Normal file
5
src/routes/(app)/library/+page.server.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import type { PageServerLoad } from './$types'
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return { user: locals.user }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
<h1>{albums.error}</h1>
|
||||
{:else if $itemDisplayState === 'list'}
|
||||
<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} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div id="library-wrapper">
|
||||
<!-- .slice is temporary to mimic performance with pagination -->
|
||||
{#each albums as album}
|
||||
<AlbumCard {album} />
|
||||
{/each}
|
||||
@@ -36,4 +38,10 @@
|
||||
/* gap: 1.5rem; */
|
||||
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>
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
import LazyImage from '$lib/components/media/lazyImage.svelte'
|
||||
import IconButton from '$lib/components/util/iconButton.svelte'
|
||||
import ArtistList from '$lib/components/media/artistList.svelte'
|
||||
import Services from '$lib/services.json'
|
||||
import { goto } from '$app/navigation'
|
||||
import { queue, newestAlert } from '$lib/stores'
|
||||
|
||||
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() {
|
||||
const itemsResponse = await fetch(`/api/connections/${album.connection.id}/album/${album.id}/items`, {
|
||||
credentials: 'include',
|
||||
@@ -20,20 +19,21 @@
|
||||
}
|
||||
|
||||
const data = (await itemsResponse.json()) as { items: Song[] }
|
||||
queueRef.setQueue(data.items)
|
||||
$queue.setQueue(data.items)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-3">
|
||||
<div id="thumbnail-wrapper" class="relative aspect-square w-full overflow-clip rounded-lg">
|
||||
<div class="overflow-hidden p-3">
|
||||
<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}`)}>
|
||||
<LazyImage thumbnailUrl={album.thumbnailUrl} alt={`${album.name} jacket`} objectFit={'cover'} />
|
||||
</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}>
|
||||
<i slot="icon" class="fa-solid fa-play text-2xl" />
|
||||
</IconButton>
|
||||
</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 class="py-2 text-center text-sm">
|
||||
<div class="line-clamp-2">{album.name}</div>
|
||||
@@ -45,15 +45,18 @@
|
||||
|
||||
<style>
|
||||
#thumbnail-wrapper:hover > #thumbnail {
|
||||
filter: brightness(40%);
|
||||
filter: brightness(35%);
|
||||
}
|
||||
#thumbnail-wrapper:hover > #play-button {
|
||||
opacity: 100%;
|
||||
opacity: 1;
|
||||
}
|
||||
/* #connection-type-icon {
|
||||
filter: grayscale();
|
||||
} */
|
||||
#thumbnail-wrapper:hover > #connection-type-icon {
|
||||
opacity: 1;
|
||||
}
|
||||
#thumbnail {
|
||||
transition: filter 150ms ease;
|
||||
}
|
||||
#play-button {
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -27,7 +27,11 @@ export const actions: Actions = {
|
||||
|
||||
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}`)
|
||||
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
|
||||
@@ -39,18 +43,18 @@ export const actions: Actions = {
|
||||
const formData = await request.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 { 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 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 newConnectionId = DB.addConnectionInfo({
|
||||
userId: locals.user.id,
|
||||
type: 'youtube-music',
|
||||
service: { userId: userChannel.id! },
|
||||
tokens: { accessToken: tokens.access_token!, refreshToken: tokens.refresh_token!, expiry: tokens.expiry_date! },
|
||||
})
|
||||
const userId = locals.user.id
|
||||
const serviceUserId = userChannel.id!
|
||||
|
||||
const newConnectionId = await DB.connections
|
||||
.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}`)
|
||||
.then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>)
|
||||
@@ -62,7 +66,7 @@ export const actions: Actions = {
|
||||
const formData = await request.formData()
|
||||
const connectionId = formData.get('connectionId')!.toString()
|
||||
|
||||
DB.deleteConnectionInfo(connectionId)
|
||||
await DB.connections.where('id', connectionId).del()
|
||||
|
||||
return { deletedConnectionId: connectionId }
|
||||
},
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
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 }) => {
|
||||
const connectionId = url.searchParams.get('connection')
|
||||
const id = url.searchParams.get('id')
|
||||
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
|
||||
const connection = Connections.getConnection(connectionId)
|
||||
const connection = await buildConnection(connectionId).catch(() => null)
|
||||
if (!connection) return new Response('Invalid connection id', { status: 400 })
|
||||
|
||||
const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' })
|
||||
|
||||
@@ -1,22 +1,19 @@
|
||||
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 }) => {
|
||||
const ids = url.searchParams.get('id')?.replace(/\s/g, '').split(',')
|
||||
if (!ids) return new Response('Missing id query parameter', { status: 400 })
|
||||
|
||||
const connections = (
|
||||
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)
|
||||
const connections = (await Promise.all(ids.map((id) => buildConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null)
|
||||
|
||||
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 })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 }) => {
|
||||
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 })
|
||||
|
||||
const albumId = url.searchParams.get('id')
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
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 }) => {
|
||||
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 })
|
||||
|
||||
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 })
|
||||
|
||||
return Response.json({ items })
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 }) => {
|
||||
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 })
|
||||
|
||||
const playlistId = url.searchParams.get('id')
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
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 }) => {
|
||||
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 })
|
||||
|
||||
const startIndexString = url.searchParams.get('startIndex')
|
||||
|
||||
@@ -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
|
||||
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 {
|
||||
const maxWidth = options?.maxWidth
|
||||
const maxHeight = options?.maxHeight
|
||||
|
||||
@@ -1,28 +1,23 @@
|
||||
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 }) => {
|
||||
const { query, userId, filter } = Object.fromEntries(url.searchParams) as { [k: string]: string | undefined }
|
||||
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 })
|
||||
|
||||
let checkedFilter: 'song' | 'album' | 'artist' | 'playlist' | undefined
|
||||
if (filter === 'song' || filter === 'album' || filter === 'artist' || filter === 'playlist') checkedFilter = filter
|
||||
|
||||
const searchResults = (
|
||||
await Promise.all(
|
||||
userConnections.map((connection) =>
|
||||
const search = (connection: Connection) =>
|
||||
connection.search(query, checkedFilter).catch((reason) => {
|
||||
console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`)
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.filter((result): result is Song | Album | Artist | Playlist => result?.id !== undefined)
|
||||
return null
|
||||
})
|
||||
|
||||
const searchResults = (await Promise.all(userConnections.map(search))).flat().filter((result): result is Song | Album | Artist | Playlist => result !== null)
|
||||
|
||||
return Response.json({ searchResults })
|
||||
}
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
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 }) => {
|
||||
const userId = params.userId!
|
||||
|
||||
const userConnections = Connections.getUserConnections(userId)
|
||||
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
|
||||
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||
|
||||
const connections = (
|
||||
await Promise.all(
|
||||
userConnections.map((connection) =>
|
||||
const getConnectionInfo = (connection: Connection) =>
|
||||
connection.getConnectionInfo().catch((reason) => {
|
||||
console.log(`Failed to fetch connection info: ${reason}`)
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
).filter((info): info is ConnectionInfo => info !== undefined)
|
||||
return null
|
||||
})
|
||||
|
||||
const connections = (await Promise.all(userConnections.map(getConnectionInfo))).filter((info): info is ConnectionInfo => info !== null)
|
||||
|
||||
return Response.json({ connections })
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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 }) => {
|
||||
const userId = params.userId!
|
||||
|
||||
const userConnections = Connections.getUserConnections(userId)
|
||||
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
|
||||
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||
|
||||
const items = (await Promise.all(userConnections.map((connection) => connection.library.albums()))).flat()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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 }) => {
|
||||
const userId = params.userId!
|
||||
|
||||
const userConnections = Connections.getUserConnections(userId)
|
||||
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
|
||||
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||
|
||||
const items = (await Promise.all(userConnections.map((connection) => connection.library.artists()))).flat()
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
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 }) => {
|
||||
const userId = params.userId!
|
||||
|
||||
const userConnections = Connections.getUserConnections(userId)
|
||||
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
|
||||
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||
|
||||
const items = (await Promise.all(userConnections.map((connection) => connection.library.playlists()))).flat()
|
||||
|
||||
@@ -1,26 +1,19 @@
|
||||
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.
|
||||
// In the future will implement more robust algorithm for offering recommendations
|
||||
export const GET: RequestHandler = async ({ params }) => {
|
||||
const userId = params.userId!
|
||||
|
||||
const userConnections = Connections.getUserConnections(userId)
|
||||
const userConnections = await buildUserConnections(params.userId!).catch(() => null)
|
||||
if (!userConnections) return new Response('Invalid user id', { status: 400 })
|
||||
|
||||
const recommendations = (
|
||||
await Promise.all(
|
||||
userConnections.map((connection) =>
|
||||
const getRecommendations = (connection: Connection) =>
|
||||
connection.getRecommendations().catch((reason) => {
|
||||
console.log(`Failed to fetch recommendations: ${reason}`)
|
||||
return undefined
|
||||
}),
|
||||
),
|
||||
)
|
||||
)
|
||||
.flat()
|
||||
.filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
|
||||
return null
|
||||
})
|
||||
|
||||
const recommendations = (await Promise.all(userConnections.map(getRecommendations))).flat().filter((recommendation): recommendation is Song | Album | Artist | Playlist => recommendation?.id !== undefined)
|
||||
|
||||
return Response.json({ recommendations })
|
||||
}
|
||||
|
||||
41
src/routes/api/v1/mixes/[mixId]/+server.ts
Normal file
41
src/routes/api/v1/mixes/[mixId]/+server.ts
Normal 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
|
||||
}
|
||||
28
src/routes/api/v1/mixes/[mixId]/items/+server.ts
Normal file
28
src/routes/api/v1/mixes/[mixId]/items/+server.ts
Normal 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 }) => {}
|
||||
30
src/routes/api/v1/users/[userId]/mixes/+server.ts
Normal file
30
src/routes/api/v1/users/[userId]/mixes/+server.ts
Normal 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 })
|
||||
}
|
||||
@@ -15,7 +15,7 @@ export const actions: Actions = {
|
||||
const formData = await request.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' })
|
||||
|
||||
const passwordValid = await compare(password.toString(), user.passwordHash)
|
||||
@@ -34,8 +34,20 @@ export const actions: Actions = {
|
||||
const { username, password } = Object.fromEntries(formData)
|
||||
|
||||
const passwordHash = await hash(password.toString(), 10)
|
||||
const newUser = DB.addUser(username.toString(), passwordHash)
|
||||
if (!newUser) return fail(400, { message: 'Username already in use' })
|
||||
const newUser = await DB.users
|
||||
.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' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user