From 8453e51d3ff0262be833ca6c37325a76a8b2e6a9 Mon Sep 17 00:00:00 2001 From: Eclypsed Date: Thu, 4 Jul 2024 02:54:24 -0400 Subject: [PATCH] Moved to ky for requests, significant improvements to YT client implementation with ky instances --- .vscode/settings.json | 3 + package-lock.json | 1037 +++++++++-------- package.json | 3 +- src/app.d.ts | 13 +- src/lib/components/media/mediaPlayer.svelte | 212 ++-- src/lib/components/util/serviceLogo.svelte | 59 + src/lib/components/util/slider.svelte | 11 +- src/lib/server/api-helper.ts | 72 +- src/lib/server/jellyfin-types.d.ts | 78 ++ src/lib/server/jellyfin.ts | 274 ++--- src/lib/server/youtube-music-types.d.ts | 914 ++++++++++++++- src/lib/server/youtube-music.ts | 721 ++++++------ src/lib/static/jellyfin-icon.svg | 24 - src/lib/static/youtube-music-icon.svg | 28 - src/lib/stores.ts | 6 + src/routes/(app)/+layout.svelte | 3 + .../(app)/library/albums/albumCard.svelte | 6 +- src/routes/(app)/search/+page.server.ts | 6 +- src/routes/(app)/user/+page.server.ts | 32 +- src/routes/(app)/user/+page.svelte | 11 +- src/routes/api/connections/+server.ts | 4 +- .../[connectionId]/album/+server.ts | 4 +- .../album/[albumId]/items/+server.ts | 4 +- .../[connectionId]/playlist/+server.ts | 4 +- .../playlist/[playlistId]/items/+server.ts | 4 +- src/routes/api/search/+server.ts | 23 - .../api/users/[userId]/connections/+server.ts | 4 +- .../users/[userId]/library/albums/+server.ts | 4 +- .../users/[userId]/library/artists/+server.ts | 4 +- .../[userId]/library/playlists/+server.ts | 4 +- .../users/[userId]/recommendations/+server.ts | 4 +- src/routes/api/{ => v1}/audio/+server.ts | 4 +- src/routes/api/v1/search/+server.ts | 35 + 33 files changed, 2245 insertions(+), 1370 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/lib/components/util/serviceLogo.svelte create mode 100644 src/lib/server/jellyfin-types.d.ts delete mode 100644 src/lib/static/jellyfin-icon.svg delete mode 100644 src/lib/static/youtube-music-icon.svg delete mode 100644 src/routes/api/search/+server.ts rename src/routes/api/{ => v1}/audio/+server.ts (88%) create mode 100644 src/routes/api/v1/search/+server.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..72446f4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib" +} diff --git a/package-lock.json b/package-lock.json index a450d6a..9302e9d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,10 @@ "bcrypt-ts": "^5.0.1", "better-sqlite3": "^9.3.0", "fast-average-color": "^9.4.0", - "googleapis": "^133.0.0", + "googleapis": "^140.0.1", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", + "ky": "^1.4.0", "zod": "^3.23.8" }, "devDependencies": { @@ -49,22 +50,22 @@ } }, "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "dev": true, "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", - "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", "cpu": [ "ppc64" ], @@ -78,9 +79,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.20.2.tgz", - "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", "cpu": [ "arm" ], @@ -94,9 +95,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", - "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", "cpu": [ "arm64" ], @@ -110,9 +111,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.20.2.tgz", - "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", "cpu": [ "x64" ], @@ -126,9 +127,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", - "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", "cpu": [ "arm64" ], @@ -142,9 +143,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", - "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", "cpu": [ "x64" ], @@ -158,9 +159,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", - "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", "cpu": [ "arm64" ], @@ -174,9 +175,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", - "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", "cpu": [ "x64" ], @@ -190,9 +191,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", - "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", "cpu": [ "arm" ], @@ -206,9 +207,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", - "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", "cpu": [ "arm64" ], @@ -222,9 +223,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", - "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", "cpu": [ "ia32" ], @@ -238,9 +239,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", - "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", "cpu": [ "loong64" ], @@ -254,9 +255,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", - "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", "cpu": [ "mips64el" ], @@ -270,9 +271,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", - "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", "cpu": [ "ppc64" ], @@ -286,9 +287,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", - "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", "cpu": [ "riscv64" ], @@ -302,9 +303,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", - "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", "cpu": [ "s390x" ], @@ -318,9 +319,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", - "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", "cpu": [ "x64" ], @@ -334,9 +335,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", - "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", "cpu": [ "x64" ], @@ -350,9 +351,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", - "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", "cpu": [ "x64" ], @@ -366,9 +367,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", - "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", "cpu": [ "x64" ], @@ -382,9 +383,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", - "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", "cpu": [ "arm64" ], @@ -398,9 +399,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", - "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", "cpu": [ "ia32" ], @@ -414,9 +415,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", - "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", "cpu": [ "x64" ], @@ -430,9 +431,9 @@ } }, "node_modules/@fortawesome/fontawesome-free": { - "version": "6.5.1", - "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz", - "integrity": "sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw==", + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.2.tgz", + "integrity": "sha512-hRILoInAx8GNT5IMkrtIt9blOdrqHOnPBH+k70aWUAqPZPgopb9G5EQJFpaBx/S8zp2fC+mPW349Bziuk1o28Q==", "hasInstallScript": true, "engines": { "node": ">=6" @@ -456,32 +457,32 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", "dev": true, "dependencies": { - "@jridgewell/set-array": "^1.0.1", + "@jridgewell/set-array": "^1.2.1", "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" + "@jridgewell/trace-mapping": "^0.3.24" }, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", - "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "dev": true, "engines": { "node": ">=6.0.0" } }, "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", "dev": true, "engines": { "node": ">=6.0.0" @@ -494,9 +495,9 @@ "dev": true }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", "dev": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -549,15 +550,15 @@ } }, "node_modules/@polka/url": { - "version": "1.0.0-next.24", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.24.tgz", - "integrity": "sha512-2LuNTFBIO0m7kKIQvvPHN6UE63VjpmL9rnEEaOOaiSPbZK+zUOYIzBAWcED+3XYzhYsd/0mD57VdxAEqqV52CQ==", + "version": "1.0.0-next.25", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.25.tgz", + "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", "dev": true }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.14.1.tgz", - "integrity": "sha512-fH8/o8nSUek8ceQnT7K4EQbSiV7jgkHq81m9lWZFIXjJ7lJzpWXbQFpT/Zh6OZYnpFykvzC3fbEvEAFZu03dPA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz", + "integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==", "cpu": [ "arm" ], @@ -568,9 +569,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.14.1.tgz", - "integrity": "sha512-Y/9OHLjzkunF+KGEoJr3heiD5X9OLa8sbT1lm0NYeKyaM3oMhhQFvPB0bNZYJwlq93j8Z6wSxh9+cyKQaxS7PQ==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz", + "integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==", "cpu": [ "arm64" ], @@ -581,9 +582,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.14.1.tgz", - "integrity": "sha512-+kecg3FY84WadgcuSVm6llrABOdQAEbNdnpi5X3UwWiFVhZIZvKgGrF7kmLguvxHNQy+UuRV66cLVl3S+Rkt+Q==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz", + "integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==", "cpu": [ "arm64" ], @@ -594,9 +595,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.14.1.tgz", - "integrity": "sha512-2pYRzEjVqq2TB/UNv47BV/8vQiXkFGVmPFwJb+1E0IFFZbIX8/jo1olxqqMbo6xCXf8kabANhp5bzCij2tFLUA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz", + "integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==", "cpu": [ "x64" ], @@ -607,9 +608,22 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.14.1.tgz", - "integrity": "sha512-mS6wQ6Do6/wmrF9aTFVpIJ3/IDXhg1EZcQFYHZLHqw6AzMBjTHWnCG35HxSqUNphh0EHqSM6wRTT8HsL1C0x5g==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz", + "integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz", + "integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==", "cpu": [ "arm" ], @@ -620,9 +634,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.14.1.tgz", - "integrity": "sha512-p9rGKYkHdFMzhckOTFubfxgyIO1vw//7IIjBBRVzyZebWlzRLeNhqxuSaZ7kCEKVkm/kuC9fVRW9HkC/zNRG2w==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz", + "integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==", "cpu": [ "arm64" ], @@ -633,9 +647,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.14.1.tgz", - "integrity": "sha512-nDY6Yz5xS/Y4M2i9JLQd3Rofh5OR8Bn8qe3Mv/qCVpHFlwtZSBYSPaU4mrGazWkXrdQ98GB//H0BirGR/SKFSw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz", + "integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==", "cpu": [ "arm64" ], @@ -646,11 +660,11 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.14.1.tgz", - "integrity": "sha512-im7HE4VBL+aDswvcmfx88Mp1soqL9OBsdDBU8NqDEYtkri0qV0THhQsvZtZeNNlLeCUQ16PZyv7cqutjDF35qw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz", + "integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==", "cpu": [ - "ppc64le" + "ppc64" ], "dev": true, "optional": true, @@ -659,9 +673,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.14.1.tgz", - "integrity": "sha512-RWdiHuAxWmzPJgaHJdpvUUlDz8sdQz4P2uv367T2JocdDa98iRw2UjIJ4QxSyt077mXZT2X6pKfT2iYtVEvOFw==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz", + "integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==", "cpu": [ "riscv64" ], @@ -672,9 +686,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.14.1.tgz", - "integrity": "sha512-VMgaGQ5zRX6ZqV/fas65/sUGc9cPmsntq2FiGmayW9KMNfWVG/j0BAqImvU4KTeOOgYSf1F+k6at1UfNONuNjA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz", + "integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==", "cpu": [ "s390x" ], @@ -685,9 +699,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.14.1.tgz", - "integrity": "sha512-9Q7DGjZN+hTdJomaQ3Iub4m6VPu1r94bmK2z3UeWP3dGUecRC54tmVu9vKHTm1bOt3ASoYtEz6JSRLFzrysKlA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz", + "integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==", "cpu": [ "x64" ], @@ -698,9 +712,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.14.1.tgz", - "integrity": "sha512-JNEG/Ti55413SsreTguSx0LOVKX902OfXIKVg+TCXO6Gjans/k9O6ww9q3oLGjNDaTLxM+IHFMeXy/0RXL5R/g==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz", + "integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==", "cpu": [ "x64" ], @@ -711,9 +725,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.14.1.tgz", - "integrity": "sha512-ryS22I9y0mumlLNwDFYZRDFLwWh3aKaC72CWjFcFvxK0U6v/mOkM5Up1bTbCRAhv3kEIwW2ajROegCIQViUCeA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz", + "integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==", "cpu": [ "arm64" ], @@ -724,9 +738,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.14.1.tgz", - "integrity": "sha512-TdloItiGk+T0mTxKx7Hp279xy30LspMso+GzQvV2maYePMAWdmrzqSNZhUpPj3CGw12aGj57I026PgLCTu8CGg==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz", + "integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==", "cpu": [ "ia32" ], @@ -737,9 +751,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.14.1.tgz", - "integrity": "sha512-wQGI+LY/Py20zdUPq+XCem7JcPOyzIJBm3dli+56DJsQOHbnXZFEwgmnC6el1TPAfC8lBT3m+z69RmLykNUbew==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz", + "integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==", "cpu": [ "x64" ], @@ -750,29 +764,29 @@ ] }, "node_modules/@sveltejs/adapter-auto": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.1.1.tgz", - "integrity": "sha512-6LeZft2Fo/4HfmLBi5CucMYmgRxgcETweQl/yQoZo/895K3S9YWYN4Sfm/IhwlIpbJp3QNvhKmwCHbsqQNYQpw==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.2.2.tgz", + "integrity": "sha512-Mso5xPCA8zgcKrv+QioVlqMZkyUQ5MjDJiEPuG/Z7cV/5tmwV7LmcVWk5tZ+H0NCOV1x12AsoSpt/CwFwuVXMA==", "dev": true, "dependencies": { - "import-meta-resolve": "^4.0.0" + "import-meta-resolve": "^4.1.0" }, "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "node_modules/@sveltejs/kit": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.4.3.tgz", - "integrity": "sha512-nKNhUdt61vtD961kQpUk6vLDhpnV0yku5F1uYNWvrJYFV0+cGfmW7ol0JVMSjHMXlMtmmv2FTc+nPRrTFwb2UA==", + "version": "2.5.18", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.5.18.tgz", + "integrity": "sha512-+g06hvpVAnH7b4CDjhnTDgFWBKBiQJpuSmQeGYOuzbO3SC3tdYjRNlDCrafvDtKbGiT2uxY5Dn9qdEUGVZdWOQ==", "dev": true, "hasInstallScript": true, "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0", - "devalue": "^4.3.2", + "devalue": "^5.0.0", "esm-env": "^1.0.0", - "import-meta-resolve": "^4.0.0", + "import-meta-resolve": "^4.1.0", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", @@ -794,17 +808,17 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.0.1.tgz", - "integrity": "sha512-CGURX6Ps+TkOovK6xV+Y2rn8JKa8ZPUHPZ/NKgCxAmgBrXReavzFl8aOSCj3kQ1xqT7yGJj53hjcV/gqwDAaWA==", + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-3.1.1.tgz", + "integrity": "sha512-rimpFEAboBBHIlzISibg94iP09k/KYdHgVhJlcsTfn7KMBhc70jFX/GRWkRdFCc2fdnk+4+Bdfej23cMDnJS6A==", "dev": true, "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^2.0.0-next.0 || ^2.0.0", + "@sveltejs/vite-plugin-svelte-inspector": "^2.1.0", "debug": "^4.3.4", "deepmerge": "^4.3.1", "kleur": "^4.1.5", - "magic-string": "^0.30.5", - "svelte-hmr": "^0.15.3", + "magic-string": "^0.30.10", + "svelte-hmr": "^0.16.0", "vitefu": "^0.2.5" }, "engines": { @@ -816,9 +830,9 @@ } }, "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.0.0.tgz", - "integrity": "sha512-gjr9ZFg1BSlIpfZ4PRewigrvYmHWbDrq2uvvPB1AmTWKuM+dI1JXQSUu2pIrYLb/QncyiIGkFDFKTwJ0XqQZZg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-2.1.0.tgz", + "integrity": "sha512-9QX28IymvBlSCqsCll5t0kQVxipsfhFFL+L2t3nTWfXnddYwxBuAEtTtlaVQpRz9c37BhJjltSeY4AJSC03SSg==", "dev": true, "dependencies": { "debug": "^4.3.4" @@ -833,9 +847,9 @@ } }, "node_modules/@types/better-sqlite3": { - "version": "7.6.10", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.10.tgz", - "integrity": "sha512-TZBjD+yOsyrUJGmcUj6OS3JADk3+UZcNv3NOBqGkM09bZdi28fNZw8ODqbMOLfKCu7RYCO62/ldq1iHbzxqoPw==", + "version": "7.6.11", + "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-7.6.11.tgz", + "integrity": "sha512-i8KcD3PgGtGBLl3+mMYA8PdKkButvPyARxA7IQAd6qeslht13qxb1zzO8dRCtE7U3IoJS782zDBAeoKiM695kg==", "dependencies": { "@types/node": "*" } @@ -853,17 +867,17 @@ "dev": true }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.5.tgz", - "integrity": "sha512-VRLSGzik+Unrup6BsouBeHsf4d1hOEgYWTm/7Nmw1sXoN1+tRly/Gy/po3yeahnP4jfnQWWAhQAqcNfH7ngOkA==", + "version": "9.0.6", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.6.tgz", + "integrity": "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==", "dependencies": { "@types/node": "*" } }, "node_modules/@types/node": { - "version": "20.11.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.5.tgz", - "integrity": "sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==", + "version": "20.14.9", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", + "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", "dependencies": { "undici-types": "~5.26.4" } @@ -875,9 +889,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.12.1", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", + "integrity": "sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -887,9 +901,9 @@ } }, "node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.1.tgz", + "integrity": "sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==", "dependencies": { "debug": "^4.3.4" }, @@ -956,9 +970,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.17", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.17.tgz", - "integrity": "sha512-/cpVNRLSfhOtcGflT13P2794gVSgmPgTR+erw5ifnMLZb0UnSlkK4tquLmkd3BhA+nLo5tX8Cu0upUsGKvKbmg==", + "version": "10.4.19", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.19.tgz", + "integrity": "sha512-BaENR2+zBZ8xXhM4pUaKUxlVdxZ0EZhjvbopwnXmxRUfqDmwSpC2lAi/QXvx7NRdPCo1WKEcEF6mV64si1z4Ew==", "dev": true, "funding": [ { @@ -975,8 +989,8 @@ } ], "dependencies": { - "browserslist": "^4.22.2", - "caniuse-lite": "^1.0.30001578", + "browserslist": "^4.23.0", + "caniuse-lite": "^1.0.30001599", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.0.0", @@ -1027,17 +1041,17 @@ ] }, "node_modules/bcrypt-ts": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/bcrypt-ts/-/bcrypt-ts-5.0.1.tgz", - "integrity": "sha512-+Q6wjkT+PO0Da56BIyaYyueMeqAV/zOXqfFIssRgCbQLGwU+YkBJfBpP2Q9Q8hGbpDyDNCrG36npSdE+S9HWUA==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-ts/-/bcrypt-ts-5.0.2.tgz", + "integrity": "sha512-gDwQ5784AkkfhHACh3jGcg1hUubyZyeq9AtVd5gXkcyHGVOC+mORjRIHSj+fHfqwY5vxwyBLXQpcfk8MpK0ROg==", "engines": { "node": ">=18" } }, "node_modules/better-sqlite3": { - "version": "9.3.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.3.0.tgz", - "integrity": "sha512-ww73jVpQhRRdS9uMr761ixlkl4bWoXi8hMQlBGhoN6vPNlUHpIsNmw4pKN6kjknlt/wopdvXHvLk1W75BI+n0Q==", + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-9.6.0.tgz", + "integrity": "sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==", "hasInstallScript": true, "dependencies": { "bindings": "^1.5.0", @@ -1053,12 +1067,15 @@ } }, "node_modules/binary-extensions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz", - "integrity": "sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", "dev": true, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/bindings": { @@ -1102,9 +1119,9 @@ } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.23.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", + "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", "dev": true, "funding": [ { @@ -1121,10 +1138,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", + "caniuse-lite": "^1.0.30001629", + "electron-to-chromium": "^1.4.796", "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "update-browserslist-db": "^1.0.16" }, "bin": { "browserslist": "cli.js" @@ -1157,12 +1174,12 @@ } }, "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz", + "integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==", "dev": true, "engines": { - "node": "*" + "node": ">=8.0.0" } }, "node_modules/buffer-equal-constant-time": { @@ -1188,15 +1205,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/callsites": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", - "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -1207,9 +1215,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001636", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001636.tgz", - "integrity": "sha512-bMg2vmr8XBsbL6Lr0UHXy/21m84FTxDLWn2FSqMd5PrlbMxwJlQnC2YWYxVgp66PZE+BBNF2jYQUBKCo1FDeZg==", + "version": "1.0.30001640", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz", + "integrity": "sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==", "dev": true, "funding": [ { @@ -1227,16 +1235,10 @@ ] }, "node_modules/chokidar": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", - "integrity": "sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "dev": true, - "funding": [ - { - "type": "individual", - "url": "https://paulmillr.com/funding/" - } - ], "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", @@ -1249,6 +1251,9 @@ "engines": { "node": ">= 8.10.0" }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, "optionalDependencies": { "fsevents": "~2.3.2" } @@ -1295,12 +1300,11 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==" }, "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", "engines": { - "node": ">= 6" + "node": ">=14" } }, "node_modules/concat-map": { @@ -1358,9 +1362,9 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dependencies": { "ms": "2.1.2" }, @@ -1439,17 +1443,17 @@ } }, "node_modules/detect-libc": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", - "integrity": "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", + "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", "engines": { "node": ">=8" } }, "node_modules/devalue": { - "version": "4.3.2", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-4.3.2.tgz", - "integrity": "sha512-KqFl6pOgOW+Y6wJgu80rHpo2/3H07vr8ntR9rkkFIRETewbf5GaYYcakYfiKz89K+sLsuPkQIZaXDMjUObZwWg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.0.0.tgz", + "integrity": "sha512-gO+/OMXF7488D+u3ue+G7Y4AA3ZmUnB3eHJXmBTgNHvr4ZNzl36A0ZtG+XCRNYCkYx/bFmw4qtkoFLa+wSrwAA==", "dev": true }, "node_modules/didyoumean": { @@ -1479,9 +1483,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.640", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.640.tgz", - "integrity": "sha512-z/6oZ/Muqk4BaE7P69bXhUhpJbUM9ZJeka43ZwxsDshKtePns4mhBlh8bU5+yrnOnz3fhG82XLzGUXazOmsWnA==", + "version": "1.4.816", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.816.tgz", + "integrity": "sha512-EKH5X5oqC6hLmiS7/vYtZHZFTNdhsYG5NVPRN6Yn0kQHNBlT59+xSM8HBy66P5fxWpKgZbPqb+diC64ng295Jw==", "dev": true }, "node_modules/emoji-regex": { @@ -1524,9 +1528,9 @@ "dev": true }, "node_modules/esbuild": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.20.2.tgz", - "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", "dev": true, "hasInstallScript": true, "bin": { @@ -1536,35 +1540,35 @@ "node": ">=12" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.20.2", - "@esbuild/android-arm": "0.20.2", - "@esbuild/android-arm64": "0.20.2", - "@esbuild/android-x64": "0.20.2", - "@esbuild/darwin-arm64": "0.20.2", - "@esbuild/darwin-x64": "0.20.2", - "@esbuild/freebsd-arm64": "0.20.2", - "@esbuild/freebsd-x64": "0.20.2", - "@esbuild/linux-arm": "0.20.2", - "@esbuild/linux-arm64": "0.20.2", - "@esbuild/linux-ia32": "0.20.2", - "@esbuild/linux-loong64": "0.20.2", - "@esbuild/linux-mips64el": "0.20.2", - "@esbuild/linux-ppc64": "0.20.2", - "@esbuild/linux-riscv64": "0.20.2", - "@esbuild/linux-s390x": "0.20.2", - "@esbuild/linux-x64": "0.20.2", - "@esbuild/netbsd-x64": "0.20.2", - "@esbuild/openbsd-x64": "0.20.2", - "@esbuild/sunos-x64": "0.20.2", - "@esbuild/win32-arm64": "0.20.2", - "@esbuild/win32-ia32": "0.20.2", - "@esbuild/win32-x64": "0.20.2" + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" } }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", + "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", "engines": { "node": ">=6" } @@ -1630,9 +1634,9 @@ } }, "node_modules/fastq": { - "version": "1.16.0", - "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.16.0.tgz", - "integrity": "sha512-ifCoaXsDrsdkWTtiNJX5uzHDsrck5TzfKKDcuFFTIrrc/BS076qgEIfoIy1VeZqViznfKiysPYTh/QeHtnIsYA==", + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", "dev": true, "dependencies": { "reusify": "^1.0.4" @@ -1656,9 +1660,9 @@ } }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", + "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", "dev": true, "dependencies": { "cross-spawn": "^7.0.0", @@ -1718,14 +1722,15 @@ } }, "node_modules/gaxios": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.3.0.tgz", - "integrity": "sha512-p+ggrQw3fBwH2F5N/PAI4k/G/y1art5OxKpb2J2chwNNHM4hHuAOtivjPuirMF4KNKwTTUal/lPfL2+7h2mEcg==", + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-6.7.0.tgz", + "integrity": "sha512-DSrkyMTfAnAm4ks9Go20QGOcXEyW/NmZhvTYBU2rb4afBB393WIMQPWPEDMl/k8xqiNN9HYq2zao3oWXsdl2Tg==", "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" + "node-fetch": "^2.6.9", + "uuid": "^10.0.0" }, "engines": { "node": ">=14" @@ -1783,6 +1788,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dev": true, "dependencies": { "fs.realpath": "^1.0.0", @@ -1824,9 +1830,9 @@ "dev": true }, "node_modules/google-auth-library": { - "version": "9.6.3", - "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.6.3.tgz", - "integrity": "sha512-4CacM29MLC2eT9Cey5GDVK4Q8t+MMp8+OEdOaqD9MG6b0dOyLORaaeJMPQ7EESVgm/+z5EKYyFLxgzBJlJgyHQ==", + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-9.11.0.tgz", + "integrity": "sha512-epX3ww/mNnhl6tL45EQ/oixsY8JLEgUFoT4A5E/5iAR4esld9Kqv6IJGk7EmGuOgDvaarwF95hU2+v7Irql9lw==", "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", @@ -1859,9 +1865,9 @@ } }, "node_modules/googleapis": { - "version": "133.0.0", - "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-133.0.0.tgz", - "integrity": "sha512-6xyc49j+x7N4smawJs/q1i7mbSkt6SYUWWd9RbsmmDW7gRv+mhwZ4xT+XkPihZcNyo/diF//543WZq4szdS74w==", + "version": "140.0.1", + "resolved": "https://registry.npmjs.org/googleapis/-/googleapis-140.0.1.tgz", + "integrity": "sha512-ZGvBX4mQcFXO9ACnVNg6Aqy3KtBPB5zTuue43YVLxwn8HSv8jB7w+uDKoIPSoWuxGROgnj2kbng6acXncOQRNA==", "dependencies": { "google-auth-library": "^9.0.0", "googleapis-common": "^7.0.0" @@ -1871,13 +1877,13 @@ } }, "node_modules/googleapis-common": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.0.1.tgz", - "integrity": "sha512-mgt5zsd7zj5t5QXvDanjWguMdHAcJmmDrF9RkInCecNsyV7S7YtGqm5v2IWONNID88osb7zmx5FtrAP12JfD0w==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/googleapis-common/-/googleapis-common-7.2.0.tgz", + "integrity": "sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==", "dependencies": { "extend": "^3.0.2", "gaxios": "^6.0.3", - "google-auth-library": "^9.0.0", + "google-auth-library": "^9.7.0", "qs": "^6.7.0", "url-template": "^2.0.8", "uuid": "^9.0.0" @@ -1886,6 +1892,18 @@ "node": ">=14.0.0" } }, + "node_modules/googleapis-common/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/gopd": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", @@ -1968,9 +1986,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -1979,9 +1997,9 @@ } }, "node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz", + "integrity": "sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==", "dependencies": { "agent-base": "^7.0.2", "debug": "4" @@ -2009,26 +2027,10 @@ } ] }, - "node_modules/import-fresh": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.0.tgz", - "integrity": "sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==", - "dev": true, - "dependencies": { - "parent-module": "^1.0.0", - "resolve-from": "^4.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/import-meta-resolve": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.0.0.tgz", - "integrity": "sha512-okYUR7ZQPH+efeuMJGlq4f8ubUgO50kByRPyt/Cy1Io4PSRsPjxME+YlVaCOx+NIToW7hCsZNFJyTPFFKepRSA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.1.0.tgz", + "integrity": "sha512-I6fiaX09Xivtk+THaMfAwnA3MVA5Big1WHF1Dfx9hFuvNIWpXnorlkzhcQf6ehrqQiiZECRt1poOAkPmer3ruw==", "dev": true, "funding": { "type": "github", @@ -2039,6 +2041,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, "dependencies": { "once": "^1.3.0", @@ -2076,11 +2079,14 @@ } }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", + "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2152,9 +2158,9 @@ "dev": true }, "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", + "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", "dev": true, "dependencies": { "@isaacs/cliui": "^8.0.2" @@ -2170,9 +2176,9 @@ } }, "node_modules/jiti": { - "version": "1.21.0", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.0.tgz", - "integrity": "sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==", + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", "dev": true, "bin": { "jiti": "bin/jiti.js" @@ -2285,20 +2291,31 @@ } } }, - "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==", + "node_modules/knex/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dependencies": { + "ms": "2.1.2" + }, "engines": { - "node": ">=14" + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "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==", + "node_modules/ky": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ky/-/ky-1.4.0.tgz", + "integrity": "sha512-tPhhoGUiEiU/WXR4rt8klIoLdnTtyu+9jVKHd/wauEjYud32jyn63mzKWQweaQrHWxBQtYoVtdcEnYX1LosnFQ==", "engines": { - "node": ">=8" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sindresorhus/ky?sponsor=1" } }, "node_modules/lilconfig": { @@ -2363,24 +2380,21 @@ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" }, "node_modules/lru-cache": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.1.0.tgz", - "integrity": "sha512-/1clY/ui8CzjKFyjdvwPWJUYKiFVXG2I2cY0ssG7h4+hwk+XOIX7ZSG9Q7TW8TW3Kp3BUSqgFWBLgL4PJ+Blag==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.3.0.tgz", + "integrity": "sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ==", "dev": true, "engines": { "node": "14 || >=16.14" } }, "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", + "version": "0.30.10", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.10.tgz", + "integrity": "sha512-iIRwTIf0QKV3UAnYK4PU8uiEc4SRh5jX0mwpIwETPpHdhVM4f53RSwS/vXvN1JhGX+Cs7B8qIq3d6AH49O5fAQ==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" } }, "node_modules/mdn-data": { @@ -2399,12 +2413,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz", + "integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==", "dev": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -2452,9 +2466,9 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "dev": true, "engines": { "node": ">=16 || 14 >=14.17" @@ -2535,9 +2549,9 @@ "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==" }, "node_modules/node-abi": { - "version": "3.54.0", - "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.54.0.tgz", - "integrity": "sha512-p7eGEiQil0YUV3ItH4/tBb781L5impVmmx2E9FRKF7d18XXzp4PGT2tdYMFY6wQqgxD0IwNZOiSJ0/K0fSi/OA==", + "version": "3.65.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.65.0.tgz", + "integrity": "sha512-ThjYBfoDNr08AWx6hGaRbfPwxKV9kVzAzOzlLKbk2CuqXE2xnCh+cbAGnwM3t8Lq4v9rUB7VfondlkBckcJrVA==", "dependencies": { "semver": "^7.3.5" }, @@ -2607,9 +2621,12 @@ } }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", + "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -2622,17 +2639,11 @@ "wrappy": "1" } }, - "node_modules/parent-module": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", - "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", - "dev": true, - "dependencies": { - "callsites": "^3.0.0" - }, - "engines": { - "node": ">=6" - } + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "dev": true }, "node_modules/path-is-absolute": { "version": "1.0.1", @@ -2658,16 +2669,16 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", "dev": true, "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -2690,9 +2701,9 @@ "integrity": "sha512-ch6OwaeaPYcova4kKZ15sbJ2hKb/VP48ZD2gE7i1J+L4MspCtBMAx8nMgz7bksc7IojCIIWuEhHibSMFH8m8oA==" }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { @@ -2726,9 +2737,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "dev": true, "funding": [ { @@ -2746,7 +2757,7 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -2825,12 +2836,15 @@ } }, "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.0.0.tgz", - "integrity": "sha512-K2U4W2Ff5ibV7j7ydLr+zLAkIg5JJ4lPn1Ltsdt+Tz/IjQ8buJ55pZAxoP34lqIiwtF9iAvtLv3JGv7CAyAg+g==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.2.tgz", + "integrity": "sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow==", "dev": true, "engines": { "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" } }, "node_modules/postcss-nested": { @@ -2853,9 +2867,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.0.15", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.15.tgz", - "integrity": "sha512-rEYkQOMUCEMhsKbK66tbEU9QVIxbhN18YiniAwA7XQYTVBqrBy+P2p5JcdqsHgKM2zWylp8d7J6eszocfds5Sw==", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.0.tgz", + "integrity": "sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -2872,9 +2886,9 @@ "dev": true }, "node_modules/prebuild-install": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.1.tgz", - "integrity": "sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.2.tgz", + "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==", "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", @@ -2897,9 +2911,9 @@ } }, "node_modules/prettier": { - "version": "3.2.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.4.tgz", - "integrity": "sha512-FWu1oLHKCrtpO1ypU6J0SbK2d9Ckwysq6bHj/uaCP26DxrPpppCLQRGVuqAxSTvhF00AcvDRyYrLNW7ocBhFFQ==", + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.2.tgz", + "integrity": "sha512-rAVeHYMcv8ATV5d508CFdn+8/pHPpXeIid1DdrPwXnaAdH7cqjVbpJaT5eq4yRAFU/lsbwYwSF/n5iNrdJHPQA==", "dev": true, "bin": { "prettier": "bin/prettier.cjs" @@ -2912,9 +2926,9 @@ } }, "node_modules/prettier-plugin-svelte": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.1.2.tgz", - "integrity": "sha512-7xfMZtwgAWHMT0iZc8jN4o65zgbAQ3+O32V6W7pXrqNvKnHnkoyQCGCbKeUyXKZLbYE0YhFRnamfxfkEGxm8qA==", + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/prettier-plugin-svelte/-/prettier-plugin-svelte-3.2.5.tgz", + "integrity": "sha512-vP/M/Goc8z4iVIvrwXwbrYVjJgA0Hf8PO1G4LBh/ocSt6vUP6sLvyu9F3ABEGr+dbKyxZjEKLkeFsWy/yYl0HQ==", "dev": true, "peerDependencies": { "prettier": "^3.0.0", @@ -2922,9 +2936,9 @@ } }, "node_modules/prettier-plugin-tailwindcss": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.11.tgz", - "integrity": "sha512-AvI/DNyMctyyxGOjyePgi/gqj5hJYClZ1avtQvLlqMT3uDZkRbi4HhGUpok3DRzv9z7Lti85Kdj3s3/1CeNI0w==", + "version": "0.5.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.5.14.tgz", + "integrity": "sha512-Puaz+wPUAhFp8Lo9HuciYKM2Y2XExESjeT+9NQoVFXZsPPnc9VYss2SpxdQ6vbatmt8/4+SN0oe0I1cPDABg9Q==", "dev": true, "engines": { "node": ">=14.21.3" @@ -2934,6 +2948,7 @@ "@prettier/plugin-pug": "*", "@shopify/prettier-plugin-liquid": "*", "@trivago/prettier-plugin-sort-imports": "*", + "@zackad/prettier-plugin-twig-melody": "*", "prettier": "^3.0", "prettier-plugin-astro": "*", "prettier-plugin-css-order": "*", @@ -2942,6 +2957,7 @@ "prettier-plugin-marko": "*", "prettier-plugin-organize-attributes": "*", "prettier-plugin-organize-imports": "*", + "prettier-plugin-sort-imports": "*", "prettier-plugin-style-order": "*", "prettier-plugin-svelte": "*" }, @@ -2958,6 +2974,9 @@ "@trivago/prettier-plugin-sort-imports": { "optional": true }, + "@zackad/prettier-plugin-twig-melody": { + "optional": true + }, "prettier-plugin-astro": { "optional": true }, @@ -2979,14 +2998,14 @@ "prettier-plugin-organize-imports": { "optional": true }, + "prettier-plugin-sort-imports": { + "optional": true + }, "prettier-plugin-style-order": { "optional": true }, "prettier-plugin-svelte": { "optional": true - }, - "prettier-plugin-twig-melody": { - "optional": true } } }, @@ -3000,11 +3019,11 @@ } }, "node_modules/qs": { - "version": "6.11.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", - "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "version": "6.12.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.2.tgz", + "integrity": "sha512-x+NLUpx9SYrcwXtX7ob1gnkSems4i/mGZX5SlYxwIau6RrUSODO89TR/XDGGpn5RPWSYIB+aSfuSlV5+CmbTBg==", "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -3109,12 +3128,11 @@ } }, "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", "engines": { - "node": ">=4" + "node": ">=8" } }, "node_modules/reusify": { @@ -3131,6 +3149,7 @@ "version": "2.7.1", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { "glob": "^7.1.3" @@ -3140,9 +3159,9 @@ } }, "node_modules/rollup": { - "version": "4.14.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.14.1.tgz", - "integrity": "sha512-4LnHSdd3QK2pa1J6dFbfm1HN0D7vSK/ZuZTsdyUAlA6Rr1yTouUTL13HaDOGJVgby461AhrNGBS7sCGXXtT+SA==", + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz", + "integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==", "dev": true, "dependencies": { "@types/estree": "1.0.5" @@ -3155,21 +3174,22 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.14.1", - "@rollup/rollup-android-arm64": "4.14.1", - "@rollup/rollup-darwin-arm64": "4.14.1", - "@rollup/rollup-darwin-x64": "4.14.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.14.1", - "@rollup/rollup-linux-arm64-gnu": "4.14.1", - "@rollup/rollup-linux-arm64-musl": "4.14.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.14.1", - "@rollup/rollup-linux-riscv64-gnu": "4.14.1", - "@rollup/rollup-linux-s390x-gnu": "4.14.1", - "@rollup/rollup-linux-x64-gnu": "4.14.1", - "@rollup/rollup-linux-x64-musl": "4.14.1", - "@rollup/rollup-win32-arm64-msvc": "4.14.1", - "@rollup/rollup-win32-ia32-msvc": "4.14.1", - "@rollup/rollup-win32-x64-msvc": "4.14.1", + "@rollup/rollup-android-arm-eabi": "4.18.0", + "@rollup/rollup-android-arm64": "4.18.0", + "@rollup/rollup-darwin-arm64": "4.18.0", + "@rollup/rollup-darwin-x64": "4.18.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.18.0", + "@rollup/rollup-linux-arm-musleabihf": "4.18.0", + "@rollup/rollup-linux-arm64-gnu": "4.18.0", + "@rollup/rollup-linux-arm64-musl": "4.18.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.18.0", + "@rollup/rollup-linux-riscv64-gnu": "4.18.0", + "@rollup/rollup-linux-s390x-gnu": "4.18.0", + "@rollup/rollup-linux-x64-gnu": "4.18.0", + "@rollup/rollup-linux-x64-musl": "4.18.0", + "@rollup/rollup-win32-arm64-msvc": "4.18.0", + "@rollup/rollup-win32-ia32-msvc": "4.18.0", + "@rollup/rollup-win32-x64-msvc": "4.18.0", "fsevents": "~2.3.2" } }, @@ -3240,12 +3260,9 @@ } }, "node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", + "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", "bin": { "semver": "bin/semver.js" }, @@ -3253,17 +3270,6 @@ "node": ">=10" } }, - "node_modules/semver/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/set-cookie-parser": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.6.0.tgz", @@ -3271,16 +3277,16 @@ "dev": true }, "node_modules/set-function-length": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.1.tgz", - "integrity": "sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", "dependencies": { - "define-data-property": "^1.1.2", + "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.3", + "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.1" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -3308,11 +3314,11 @@ } }, "node_modules/side-channel": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.5.tgz", - "integrity": "sha512-QcgiIWV4WV7qWExbN5llt6frQB/lBven9pqliLXfGPB+K9ZYXxDozp0wLkHS24kWCm+6YXH/f0HhnObZnZOBnQ==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", + "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", "dependencies": { - "call-bind": "^1.0.6", + "call-bind": "^1.0.7", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.4", "object-inspect": "^1.13.1" @@ -3394,13 +3400,13 @@ } }, "node_modules/sorcery": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.0.tgz", - "integrity": "sha512-J69LQ22xrQB1cIFJhPfgtLuI6BpWRiWu1Y3vSsIwK/eAScqJxd/+CJlUuHQRdX2C9NGFamq+KqNywGgaThwfHw==", + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/sorcery/-/sorcery-0.11.1.tgz", + "integrity": "sha512-o7npfeJE6wi6J9l0/5LKshFzZ2rMatRiCDwYeDQaOzqdzRJwALhX7mk/A/ecg6wjMu7wdZbmXfD2S/vpOg0bdQ==", "dev": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.14", - "buffer-crc32": "^0.2.5", + "buffer-crc32": "^1.0.0", "minimist": "^1.2.0", "sander": "^0.5.0" }, @@ -3572,32 +3578,42 @@ "balanced-match": "^1.0.0" } }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "engines": { + "node": ">= 6" + } + }, "node_modules/sucrase/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "version": "10.4.2", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", + "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", "dev": true, "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, "dependencies": { "brace-expansion": "^2.0.1" @@ -3621,9 +3637,9 @@ } }, "node_modules/svelte": { - "version": "4.2.9", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.9.tgz", - "integrity": "sha512-hsoB/WZGEPFXeRRLPhPrbRz67PhP6sqYgvwcAs+gWdSQSvNDw+/lTeUJSWe5h2xC97Fz/8QxAOqItwBzNJPU8w==", + "version": "4.2.18", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-4.2.18.tgz", + "integrity": "sha512-d0FdzYIiAePqRJEb90WlJDkjUEx42xhivxN8muUBmfZnP+tzUgz12DJ2hRJi8sIHCME7jeK1PTMgKPSfTd8JrA==", "dev": true, "dependencies": { "@ampproject/remapping": "^2.2.1", @@ -3646,18 +3662,16 @@ } }, "node_modules/svelte-check": { - "version": "3.6.3", - "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.6.3.tgz", - "integrity": "sha512-Q2nGnoysxUnB9KjnjpQLZwdjK62DHyW6nuH/gm2qteFnDk0lCehe/6z8TsIvYeKjC6luKaWxiNGyOcWiLLPSwA==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/svelte-check/-/svelte-check-3.8.4.tgz", + "integrity": "sha512-61aHMkdinWyH8BkkTX9jPLYxYzaAAz/FK/VQqdr2FiCQQ/q04WCwDlpGbHff1GdrMYTmW8chlTFvRWL9k0A8vg==", "dev": true, "dependencies": { "@jridgewell/trace-mapping": "^0.3.17", "chokidar": "^3.4.1", - "fast-glob": "^3.2.7", - "import-fresh": "^3.2.1", "picocolors": "^1.0.0", "sade": "^1.7.4", - "svelte-preprocess": "^5.1.0", + "svelte-preprocess": "^5.1.3", "typescript": "^5.0.3" }, "bin": { @@ -3668,9 +3682,9 @@ } }, "node_modules/svelte-hmr": { - "version": "0.15.3", - "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.15.3.tgz", - "integrity": "sha512-41snaPswvSf8TJUhlkoJBekRrABDXDMdpNpT2tfHIv4JuhgvHqLMhEPGtaQn0BmbNSTkuz2Ed20DF2eHw0SmBQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/svelte-hmr/-/svelte-hmr-0.16.0.tgz", + "integrity": "sha512-Gyc7cOS3VJzLlfj7wKS0ZnzDVdv3Pn2IuVeJPk9m2skfhcu5bq3wtIZyQGggr7/Iim5rH5cncyQft/kRLupcnA==", "dev": true, "engines": { "node": "^12.20 || ^14.13.1 || >= 16" @@ -3680,9 +3694,9 @@ } }, "node_modules/svelte-preprocess": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.3.tgz", - "integrity": "sha512-xxAkmxGHT+J/GourS5mVJeOXZzne1FR5ljeOUAMXUkfEhkLEllRreXpbl3dIYJlcJRfL1LO1uIAPpBpBfiqGPw==", + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/svelte-preprocess/-/svelte-preprocess-5.1.4.tgz", + "integrity": "sha512-IvnbQ6D6Ao3Gg6ftiM5tdbR6aAETwjhHV+UKGf5bHGYR69RQvF1ho0JKPcbUON4vy4R7zom13jPjgdOWCQ5hDA==", "dev": true, "hasInstallScript": true, "dependencies": { @@ -3693,8 +3707,7 @@ "strip-indent": "^3.0.0" }, "engines": { - "node": ">= 16.0.0", - "pnpm": "^8.0.0" + "node": ">= 16.0.0" }, "peerDependencies": { "@babel/core": "^7.10.2", @@ -3743,9 +3756,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.1.tgz", - "integrity": "sha512-qAYmXRfk3ENzuPBakNK0SRrUDipP8NQnEY6772uDhflcQz5EhRdD7JNZxyrFHVQNCwULPBn6FNPp9brpO7ctcA==", + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.4.tgz", + "integrity": "sha512-ZoyXOdJjISB7/BcLTR6SEsLgKtDStYyYZVLsUtWChO4Ps20CBad7lfJKVDiejocV4ME1hLmyY0WJE3hSDcmQ2A==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", @@ -3756,7 +3769,7 @@ "fast-glob": "^3.3.0", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", - "jiti": "^1.19.1", + "jiti": "^1.21.0", "lilconfig": "^2.1.0", "micromatch": "^4.0.5", "normalize-path": "^3.0.0", @@ -3897,9 +3910,9 @@ "dev": true }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", "dev": true }, "node_modules/tunnel-agent": { @@ -3914,9 +3927,9 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.3.tgz", + "integrity": "sha512-/hreyEujaB0w76zKo6717l3L0o/qEUtRgdvUBvlkhoWeOVMjMuHNHk0BRBzikzuGDqNmPQbg5ifMEqsHLiIUcQ==", "dev": true, "bin": { "tsc": "bin/tsc", @@ -3932,9 +3945,9 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "funding": [ { @@ -3951,8 +3964,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -3972,9 +3985,9 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -3984,13 +3997,13 @@ } }, "node_modules/vite": { - "version": "5.2.8", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.2.8.tgz", - "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==", + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz", + "integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==", "dev": true, "dependencies": { - "esbuild": "^0.20.1", - "postcss": "^8.4.38", + "esbuild": "^0.21.3", + "postcss": "^8.4.39", "rollup": "^4.13.0" }, "bin": { @@ -4177,16 +4190,14 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, - "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", + "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", "dev": true, + "bin": { + "yaml": "bin.mjs" + }, "engines": { "node": ">= 14" } diff --git a/package.json b/package.json index bd65740..6ea026d 100644 --- a/package.json +++ b/package.json @@ -33,9 +33,10 @@ "bcrypt-ts": "^5.0.1", "better-sqlite3": "^9.3.0", "fast-average-color": "^9.4.0", - "googleapis": "^133.0.0", + "googleapis": "^140.0.1", "jsonwebtoken": "^9.0.2", "knex": "^3.1.0", + "ky": "^1.4.0", "zod": "^3.23.8" } } diff --git a/src/app.d.ts b/src/app.d.ts index bc0af24..a4a5b77 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -54,14 +54,6 @@ declare global { playlist: Playlist } - type SearchFilterMap = - Filter extends 'song' ? Song : - Filter extends 'album' ? Album : - Filter extends 'artist' ? Artist : - Filter extends 'playlist' ? Playlist : - Filter extends undefined ? Song | Album | Artist | Playlist : - never - interface Connection { public readonly id: string @@ -73,15 +65,16 @@ declare global { /** * @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 + * @param {Set<'song' | 'album' | 'artist' | 'playlist'>} types A set containing any of 'song', 'album', 'artist', or 'playlist'. Specifies what media types to query * @returns {Promise<(Song | Album | Artist | Playlist)[]>} A promise of an array of media items */ - search(searchTerm: string, filter?: T): Promise[]> + search(searchTerm: string, types: Set): Promise /** * @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} A promise of response object containing the audio stream for the specified byte range + * @throws {TypeError | Error} TypeError if the id passed was invalid. Error if the connection failed to fetch the audio stream * * Fetches the audio stream for a song. Will return an response containing the audio stream if the fetch was successfull, otherwise throw an error. */ diff --git a/src/lib/components/media/mediaPlayer.svelte b/src/lib/components/media/mediaPlayer.svelte index dcd3271..6f64dfa 100644 --- a/src/lib/components/media/mediaPlayer.svelte +++ b/src/lib/components/media/mediaPlayer.svelte @@ -1,7 +1,8 @@ {#if currentlyPlaying} -
+
{#if !expanded} -
-
+
+
@@ -140,7 +146,7 @@ $queue.next()}> -
+
-
-
+
+
(volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}> @@ -183,40 +189,41 @@
{:else}
-
+
-
-
- -
-
- UP NEXT - {#each $queue.list as item} - {@const isCurrent = item === currentlyPlaying} - - {/each} -
+ {/if} +
+
+
-
-
- +
+
+ - +
-
-
+
+
- {currentlyPlaying.name} + {currentlyPlaying.name}
- {#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader} -
- +
+ {#if (currentlyPlaying.artists && currentlyPlaying.artists.length > 0) || currentlyPlaying.uploader} -
- {/if} - {#if currentlyPlaying.album} - - {/if} -
-
- - - - - +
+
+
+ (shuffled ? $queue.reorder() : $queue.shuffle())}> + + + $queue.previous()}> + + +
+ {#if waiting} + + {:else} + (paused = !paused)}> + + + {/if} +
+ $queue.clear()}> + + + $queue.next()}> + + + (loop = !loop)}> + +
-
- -
- { - if (volume > 0) localStorage.setItem('volume', volume.toString()) - }} - /> -
+
+ (volume = volume > 0 ? 0 : Number(localStorage.getItem('volume')))}> + + + { + if (volume > 0) localStorage.setItem('volume', volume.toString()) + }} + />
- - + (expanded = false)}> + +
@@ -314,7 +318,7 @@ on:waiting={() => (waiting = true)} on:ended={() => $queue.next()} on:error={() => setTimeout(() => audioElement.load(), 5000)} - src="/api/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}" + src="/api/v1/audio?connection={currentlyPlaying.connection.id}&id={currentlyPlaying.id}" {loop} />
@@ -326,26 +330,12 @@ } #expanded-player { display: grid; - grid-template-rows: calc(100% - 11rem) 11rem; - } - #song-queue-wrapper { - display: grid; - grid-template-columns: 3fr 2fr; - gap: 4rem; - } - .queue-item { - display: grid; - grid-template-columns: 5rem auto min-content; - } - #progress-bar-expanded { - display: grid; - grid-template-columns: min-content auto min-content; - align-items: center; - gap: 1rem; + grid-template-rows: 4fr 1fr; } #expanded-controls { display: grid; - gap: 1rem; + gap: 3rem; + align-items: center; grid-template-columns: 1fr min-content 1fr !important; } diff --git a/src/lib/components/util/serviceLogo.svelte b/src/lib/components/util/serviceLogo.svelte new file mode 100644 index 0000000..beccf5f --- /dev/null +++ b/src/lib/components/util/serviceLogo.svelte @@ -0,0 +1,59 @@ + + +{#if type === 'jellyfin'} + + + + + + + + + icon-transparent + + + + + +{:else if type === 'youtube-music'} + + + + + + + + + + + + + +{/if} + + diff --git a/src/lib/components/util/slider.svelte b/src/lib/components/util/slider.svelte index 52716c1..3bf6fe8 100644 --- a/src/lib/components/util/slider.svelte +++ b/src/lib/components/util/slider.svelte @@ -58,16 +58,15 @@ cursor: pointer; opacity: 0; } - #slider-track:hover > #slider-trail { - background-color: var(--slider-color); - } + #slider-track:hover > #slider-trail, #slider-track:focus > #slider-trail { background-color: var(--slider-color); } - #slider-track:hover > #slider-thumb { - opacity: 1; - } + #slider-track:hover > #slider-thumb, #slider-track:focus > #slider-thumb { opacity: 1; } + #slider-track:not(:hover):not(:focus) > #slider-trail { + transition: right 50ms linear; + } diff --git a/src/lib/server/api-helper.ts b/src/lib/server/api-helper.ts index dcab5f0..4a94128 100644 --- a/src/lib/server/api-helper.ts +++ b/src/lib/server/api-helper.ts @@ -10,39 +10,43 @@ export async function mixExists(mixId: string): Promise { return Boolean(await DB.mixes.where('id', mixId).first(DB.db.raw('EXISTS(SELECT 1)'))) } -function connectionBuilder(schema: Schemas.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) +export class ConnectionFactory { + /** + * Queries the database for a specific connection. + * + * @param {string} id The id of the connection + * @returns {Promise} An instance of a Connection + * @throws {ReferenceError} ReferenceError if there is no connection with an id matches the one passed + */ + public static async getConnection(id: string): Promise { + const schema = await DB.connections.where('id', id).first() + if (!schema) throw ReferenceError(`Connection of Id ${id} does not exist`) + + return this.createConnection(schema) + } + + /** + * Queries the database for all connections belong to a user of the specified id. + * + * @param {string} userId The id of a user + * @returns {Promise} An array of connection instances for each of the user's connections + * @throws {ReferenceError} ReferenceError if there is no user with an id matches the one passed + */ + public static async getUserConnections(userId: string): Promise { + const validUserId = await userExists(userId) + if (!validUserId) throw ReferenceError(`User of Id ${userId} does not exist`) + + const connectionSchemas = await DB.connections.where('userId', userId).select('*') + return connectionSchemas.map(this.createConnection) + } + + private static createConnection(schema: Schemas.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 { - 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 { - if (!(await userExists(userId))) throw ReferenceError(`User of Id ${userId} does not exist`) - - return (await DB.connections.where('userId', userId).select('*')).map(connectionBuilder) -} diff --git a/src/lib/server/jellyfin-types.d.ts b/src/lib/server/jellyfin-types.d.ts new file mode 100644 index 0000000..2bfc979 --- /dev/null +++ b/src/lib/server/jellyfin-types.d.ts @@ -0,0 +1,78 @@ +export namespace JellyfinAPI { + type Song = { + Name: string + Id: string + Type: 'Audio' + RunTimeTicks: number + PremiereDate?: string + ProductionYear?: number + ArtistItems?: { + Name: string + Id: string + }[] + Album?: string + AlbumId?: string + AlbumPrimaryImageTag?: string + AlbumArtists?: { + Name: string + Id: string + }[] + ImageTags?: { + Primary?: string + } + } + + type Album = { + Name: string + Id: string + Type: 'MusicAlbum' + RunTimeTicks: number + PremiereDate?: string + ProductionYear?: number + ArtistItems?: { + Name: string + Id: string + }[] + AlbumArtists?: { + Name: string + Id: string + }[] + ImageTags?: { + Primary?: string + } + } + + type Artist = { + Name: string + Id: string + Type: 'MusicArtist' + ImageTags?: { + Primary?: string + } + } + + type Playlist = { + Name: string + Id: string + Type: 'Playlist' + RunTimeTicks: number + ChildCount: number + ImageTags?: { + Primary?: string + } + } + + interface UserResponse { + Name: string + Id: string + } + + interface AuthenticationResponse { + User: JellyfinAPI.UserResponse + AccessToken: string + } + + interface SystemResponse { + ServerName: string + } +} diff --git a/src/lib/server/jellyfin.ts b/src/lib/server/jellyfin.ts index 22f2355..8ffebd2 100644 --- a/src/lib/server/jellyfin.ts +++ b/src/lib/server/jellyfin.ts @@ -1,4 +1,6 @@ import { PUBLIC_VERSION } from '$env/static/public' +import type { JellyfinAPI } from './jellyfin-types' +import ky, { HTTPError, type KyInstance } from 'ky' const jellyfinLogo = 'https://raw.githubusercontent.com/jellyfin/jellyfin-ux/55616553b692b1a6c7d8e786eeb7d8216e9b50df/branding/SVG/icon-transparent.svg' @@ -6,88 +8,89 @@ export class Jellyfin implements Connection { public readonly id: string private readonly userId: string private readonly jellyfinUserId: string + private readonly serverUrl: string - private readonly services: JellyfinServices + private readonly parsers: JellyfinParsers private libraryManager?: JellyfinLibraryManager + private readonly api: KyInstance + constructor(id: string, userId: string, jellyfinUserId: string, serverUrl: string, accessToken: string) { this.id = id this.userId = userId this.jellyfinUserId = jellyfinUserId + this.serverUrl = serverUrl - this.services = new JellyfinServices(this.id, serverUrl, accessToken) + this.parsers = new JellyfinParsers(this.id, serverUrl) + + const errorHook = (error: HTTPError) => { + console.error(`Request to ${new URL(error.request.url).pathname} failed: ${error.message} ${error.response.status}`) + return error + } + + this.api = ky.create({ + prefixUrl: serverUrl, + headers: { Authorization: `MediaBrowser Token="${accessToken}"` }, + hooks: { beforeError: [errorHook] }, + }) } public get library() { - if (!this.libraryManager) this.libraryManager = new JellyfinLibraryManager(this.jellyfinUserId, this.services) + if (!this.libraryManager) this.libraryManager = new JellyfinLibraryManager(this.jellyfinUserId, this.api, this.parsers) return this.libraryManager } + // * This method can NOT throw an error public async getConnectionInfo() { - const userEndpoint = `/Users/${this.jellyfinUserId}` - const systemEndpoint = '/System/Info' - const getUserData = () => - this.services - .request(userEndpoint) - .then((response) => response.json() as Promise) + this.api(`Users/${this.jellyfinUserId}`) + .json() .catch(() => null) - const getSystemData = () => - this.services - .request(systemEndpoint) - .then((response) => response.json() as Promise) + this.api('System/Info') + .json() .catch(() => null) const [userData, systemData] = await Promise.all([getUserData(), getSystemData()]) - if (!userData) console.error(`Fetch to ${userEndpoint} failed`) - if (!systemData) console.error(`Fetch to ${systemEndpoint} failed`) - return { id: this.id, userId: this.userId, type: 'jellyfin', - serverUrl: this.services.serverUrl().toString(), + serverUrl: this.serverUrl, serverName: systemData?.ServerName, jellyfinUserId: this.jellyfinUserId, username: userData?.Name, } satisfies ConnectionInfo } - public async search(searchTerm: string, filter: 'song'): Promise - public async search(searchTerm: string, filter: 'album'): Promise - public async search(searchTerm: string, filter: 'artist'): Promise - public async search(searchTerm: string, filter: 'playlist'): Promise - public async search(searchTerm: string, filter?: undefined): Promise<(Song | Album | Artist | Playlist)[]> - public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> { + public async search(searchTerm: string, types: Set): Promise { const filterMap = { song: 'Audio', album: 'MusicAlbum', artist: 'MusicArtist', playlist: 'Playlist' } as const const searchParams = new URLSearchParams({ searchTerm, - includeItemTypes: filter ? filterMap[filter] : Object.values(filterMap).join(','), + includeItemTypes: Array.from(types, (type) => filterMap[type]).join(','), recursive: 'true', }) - const searchResults = await this.services - .request(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) - .then((response) => response.json() as Promise<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>) + const searchResults = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: (JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist)[] }>() return searchResults.Items.map((result) => { switch (result.Type) { case 'Audio': - return this.services.parseSong(result) + return this.parsers.parseSong(result) case 'MusicAlbum': - return this.services.parseAlbum(result) + return this.parsers.parseAlbum(result) case 'MusicArtist': - return this.services.parseArtist(result) + return this.parsers.parseArtist(result) case 'Playlist': - return this.services.parsePlaylist(result) + return this.parsers.parsePlaylist(result) } - }) + }) as MediaItemTypeMap[T][] } + // Temporary implementation, I'll actually make something better later public async getRecommendations(): Promise<(Song | Album | Artist | Playlist)[]> { const searchParams = new URLSearchParams({ SortBy: 'PlayCount', @@ -97,10 +100,9 @@ export class Jellyfin implements Connection { limit: '10', }) - return this.services - .request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) - .then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>) - .then((data) => data.Items.map((song) => this.services.parseSong(song))) + const mostPlayedResponse = await this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`).json<{ Items: JellyfinAPI.Song[] }>() + + return mostPlayedResponse.Items.map(this.parsers.parseSong) } // TODO: Figure out why seeking a jellyfin song takes so much longer than ytmusic (hls?) @@ -114,14 +116,11 @@ export class Jellyfin implements Connection { userId: this.jellyfinUserId, }) - return this.services.request(`Audio/${id}/universal?${audoSearchParams.toString()}`, { headers, keepalive: true }) + return this.api(`Audio/${id}/universal?${audoSearchParams.toString()}`, { headers, keepalive: true }) } public async getAlbum(id: string) { - return this.services - .request(`/Users/${this.jellyfinUserId}/Items/${id}`) - .then((response) => response.json() as Promise) - .then(this.services.parseAlbum) + return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json().then(this.parsers.parseAlbum) } public async getAlbumItems(id: string) { @@ -130,17 +129,13 @@ export class Jellyfin implements Connection { sortBy: 'ParentIndexNumber,IndexNumber,SortName', }) - return this.services - .request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) - .then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>) - .then((data) => data.Items.map(this.services.parseSong)) + return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) + .json<{ Items: JellyfinAPI.Song[] }>() + .then((response) => response.Items.map(this.parsers.parseSong)) } public async getPlaylist(id: string) { - return this.services - .request(`/Users/${this.jellyfinUserId}/Items/${id}`) - .then((response) => response.json() as Promise) - .then(this.services.parsePlaylist) + return this.api(`Users/${this.jellyfinUserId}/Items/${id}`).json().then(this.parsers.parsePlaylist) } public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }) { @@ -152,65 +147,41 @@ export class Jellyfin implements Connection { if (options?.startIndex) searchParams.append('startIndex', options.startIndex.toString()) if (options?.limit) searchParams.append('limit', options.limit.toString()) - return this.services - .request(`/Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) - .then((response) => response.json() as Promise<{ Items: JellyfinAPI.Song[] }>) - .then((data) => data.Items.map(this.services.parseSong)) + return this.api(`Users/${this.jellyfinUserId}/Items?${searchParams.toString()}`) + .json<{ Items: JellyfinAPI.Song[] }>() + .then((response) => response.Items.map(this.parsers.parseSong)) } public static async authenticateByName(username: string, password: string, serverUrl: URL, deviceId: string): Promise { - const authUrl = new URL('/Users/AuthenticateByName', serverUrl.origin).toString() - return fetch(authUrl, { - method: 'POST', - body: JSON.stringify({ - Username: username, - Pw: password, - }), - headers: { - 'Content-Type': 'application/json; charset=utf-8', - 'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="${PUBLIC_VERSION}"`, - }, - }) - .catch(() => { - throw new JellyfinFetchError('Could not reach Jellyfin Server', 400, authUrl) - }) - .then((response) => { - if (!response.ok) throw new JellyfinFetchError('Failed to Authenticate', 401, authUrl) - return response.json() as Promise + return ky + .post(new URL('Users/AuthenticateByName', serverUrl.origin), { + headers: { + 'Content-Type': 'application/json; charset=utf-8', + 'X-Emby-Authorization': `MediaBrowser Client="Lazuli", Device="Chrome", DeviceId="${deviceId}", Version="${PUBLIC_VERSION}"`, + }, + json: { + Username: username, + Pw: password, + }, }) + .json() } } -class JellyfinServices { +class JellyfinParsers { private readonly connectionId: string + private readonly serverUrl: string - public readonly serverUrl: (endpoint?: string) => URL - public readonly request: (endpoint: string, options?: RequestInit) => Promise - - constructor(connectionId: string, serverUrl: string, accessToken: string) { + constructor(connectionId: string, serverUrl: string) { this.connectionId = connectionId - - this.serverUrl = (endpoint?: string) => new URL(endpoint ?? '', serverUrl) - - this.request = async (endpoint: string, options?: RequestInit) => { - const headers = new Headers(options?.headers) - headers.set('Authorization', `MediaBrowser Token="${accessToken}"`) - delete options?.headers - return fetch(this.serverUrl(endpoint), { headers, ...options }).then((response) => { - if (!response.ok) { - if (response.status >= 500) throw Error(`Jellyfin Server of connection ${this.connectionId} experienced and internal server error`) - throw TypeError(`Client side error in request to jellyfin server of connection ${this.connectionId}`) - } - return response - }) - } + this.serverUrl = serverUrl } private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder: string): string private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined private getBestThumbnail(item: JellyfinAPI.Song | JellyfinAPI.Album | JellyfinAPI.Artist | JellyfinAPI.Playlist, placeholder?: string): string | undefined { const imageItemId = item.ImageTags?.Primary ? item.Id : 'AlbumPrimaryImageTag' in item && item.AlbumPrimaryImageTag ? item.AlbumId : undefined - return imageItemId ? this.serverUrl(`Items/${imageItemId}/Images/Primary`).toString() : placeholder + return imageItemId ? new URL(`Items/${imageItemId}/Images/Primary`, this.serverUrl).toString() : placeholder } public parseSong = (song: JellyfinAPI.Song): Song => ({ @@ -255,122 +226,31 @@ class JellyfinServices { class JellyfinLibraryManager { private readonly jellyfinUserId: string - private readonly services: JellyfinServices + private readonly api: KyInstance + private readonly parsers: JellyfinParsers - constructor(jellyfinUserId: string, services: JellyfinServices) { + constructor(jellyfinUserId: string, api: KyInstance, parsers: JellyfinParsers) { this.jellyfinUserId = jellyfinUserId - this.services = services + this.api = api + this.parsers = parsers } public async albums(): Promise { - return this.services - .request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`) - .then((response) => response.json() as Promise<{ Items: JellyfinAPI.Album[] }>) - .then((data) => data.Items.map(this.services.parseAlbum)) + return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=MusicAlbum&recursive=true`) + .json<{ Items: JellyfinAPI.Album[] }>() + .then((response) => response.Items.map(this.parsers.parseAlbum)) } public async artists(): Promise { // ? This returns just album artists instead of all artists like in finamp, but I might decide that I want to return all artists instead - return this.services - .request('/Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true') - .then((response) => response.json() as Promise<{ Items: JellyfinAPI.Artist[] }>) - .then((data) => data.Items.map(this.services.parseArtist)) + return this.api('Artists/AlbumArtists?sortBy=SortName&sortOrder=Ascending&recursive=true') + .json<{ Items: JellyfinAPI.Artist[] }>() + .then((response) => response.Items.map(this.parsers.parseArtist)) } public async playlists(): Promise { - return this.services - .request(`/Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`) - .then((response) => response.json() as Promise<{ Items: JellyfinAPI.Playlist[] }>) - .then((data) => data.Items.map(this.services.parsePlaylist)) - } -} - -export class JellyfinFetchError extends Error { - public httpCode: number - public url: string - - constructor(message: string, httpCode: number, url: string) { - super(message) - this.httpCode = httpCode - this.url = url - } -} - -declare namespace JellyfinAPI { - type Song = { - Name: string - Id: string - Type: 'Audio' - RunTimeTicks: number - PremiereDate?: string - ProductionYear?: number - ArtistItems?: { - Name: string - Id: string - }[] - Album?: string - AlbumId?: string - AlbumPrimaryImageTag?: string - AlbumArtists?: { - Name: string - Id: string - }[] - ImageTags?: { - Primary?: string - } - } - - type Album = { - Name: string - Id: string - Type: 'MusicAlbum' - RunTimeTicks: number - PremiereDate?: string - ProductionYear?: number - ArtistItems?: { - Name: string - Id: string - }[] - AlbumArtists?: { - Name: string - Id: string - }[] - ImageTags?: { - Primary?: string - } - } - - type Artist = { - Name: string - Id: string - Type: 'MusicArtist' - ImageTags?: { - Primary?: string - } - } - - type Playlist = { - Name: string - Id: string - Type: 'Playlist' - RunTimeTicks: number - ChildCount: number - ImageTags?: { - Primary?: string - } - } - - interface UserResponse { - Name: string - Id: string - } - - interface AuthenticationResponse { - User: JellyfinAPI.UserResponse - AccessToken: string - } - - interface SystemResponse { - ServerName: string + return this.api(`Users/${this.jellyfinUserId}/Items?sortBy=SortName&sortOrder=Ascending&includeItemTypes=Playlist&recursive=true`) + .json<{ Items: JellyfinAPI.Playlist[] }>() + .then((response) => response.Items.map(this.parsers.parsePlaylist)) } } diff --git a/src/lib/server/youtube-music-types.d.ts b/src/lib/server/youtube-music-types.d.ts index c72e22d..23a067e 100644 --- a/src/lib/server/youtube-music-types.d.ts +++ b/src/lib/server/youtube-music-types.d.ts @@ -22,6 +22,44 @@ // NEW NOTE: hq720 is the same as maxresdefault. If an hq720 image is returned we don't need to query the v3 api export namespace InnerTube { + interface ErrorResponse { + error: { + code: number + message: string + status: string // 'NOT_FOUND' - Id does not exist | 'INVALID_ARGUMENT' - Invalid Id, potenially for unavailable videos | 'INTERNAL' - YouTube had a stroke + } + } + + // For response made to the browse endpoint with the user's id + namespace User { + interface Response { + contents: unknown // Whole lot of cool stuff in here that I may want to use at some point + header: { + musicVisualHeaderRenderer: { + title: { + runs: [ + { + text: string // Username + }, + ] + } + thumbnail: unknown // Contains banner art + foregroundThumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + } + } + } + } + namespace Library { interface AlbumResponse { contents: { @@ -379,14 +417,6 @@ export namespace InnerTube { } } - interface ErrorResponse { - error: { - code: number - message: string - status: string - } - } - type MusicResponsiveHeaderRenderer = { thumbnail: { musicThumbnailRenderer: { @@ -473,7 +503,8 @@ export namespace InnerTube { runs: [ { text: string // Song Name - navigationEndpoint: { + navigationEndpoint?: { + // This will be missing if the song is not playable watchEndpoint: { videoId: string watchEndpointMusicSupportedConfigs: { @@ -660,14 +691,6 @@ export namespace InnerTube { } } - interface ErrorResponse { - error: { - code: number - message: string - status: string - } - } - type MusicResponsiveListItemRenderer = { flexColumns: [ { @@ -746,7 +769,7 @@ export namespace InnerTube { interface PlayerErrorResponse { playabilityStatus: { status: 'ERROR' - reason: string + reason: string // 'This video is unavailable' - Could be invalid video id / private / blocked } } @@ -766,18 +789,18 @@ export namespace InnerTube { queueDatas: Array<{ content: | { - playlistPanelVideoRenderer: PlaylistPanelVideoRenderer // This occurs when the playlist item does not have a video or auto-generated counterpart + playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer // This occurs when the playlist item does not have a video or auto-generated counterpart } | { playlistPanelVideoWrapperRenderer: { // This occurs when the playlist has a video or auto-generated counterpart primaryRenderer: { - playlistPanelVideoRenderer: PlaylistPanelVideoRenderer + playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer } counterpart: [ { counterpartRenderer: { - playlistPanelVideoRenderer: PlaylistPanelVideoRenderer + playlistPanelVideoRenderer: PlaylistPanelVideoRenderer | BlockedPlaylistPanelVideoRenderer } }, ] @@ -786,14 +809,6 @@ export namespace InnerTube { }> } - interface ErrorResponse { - error: { - code: number - message: string - status: string - } - } - type PlaylistPanelVideoRenderer = { title: { runs: [ @@ -842,11 +857,789 @@ export namespace InnerTube { } } } + + type BlockedPlaylistPanelVideoRenderer = { + thumbnail: { + thumbnails: [ + { + url: string + }, + ] + } + navigationEndpoint: { + watchEndpoint: { + videoId: string + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_ATV' | 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC' + } + } + } + } + unplayableText: { + runs: [ + { + text: string + }, + ] + } + videoId: string + } } - // TODO: Need to fix this & it's corresponding method & add appropriate namespace - interface SearchResponse { - contents: unknown + namespace Search { + interface Response { + contents: { + tabbedSearchResultsRenderer: { + tabs: [ + { + tabRenderer: { + content: { + sectionListRenderer: { + contents: Array< + | { + itemSectionRenderer: unknown + } + | { + musicCardShelfRenderer: SongMusicCardShelfRenderer | VideoMusicCardShelfRenderer | AlbumMusicCardShelfRenderer | ArtistMusicCardShelfRenderer // I have not seen any other types of cards + } + | { + musicShelfRenderer: + | SongsMusicShelfRenderer + | VideosMusicShelfRenderer + | AlbumsMusicShelfRenderer + | CommunityPlaylistsMusicShelfRenderer + | ArtistsMusicShelfRenderer + | PodcastsMusicShelfRenderer + | EpisodesMusicShelfRenderer + | ProfilesMusicShelfRenderer + } + > + } + } + } + }, + // There is a library tab when no filter is specified, but I don't plan on utilizing it anyway + ] + } + } + } + + type SongMusicResponsiveListItemRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string // Duration will be in one of these + navigationEndpoint: { + watchEndpoint: { + videoId: string + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_ATV' + } + } + } + } + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + }> + } + } + }, + // There is a third musicResponsiveListItemFlexColumnRenderer but it only contains view count + ] + } + + type VideoMusicResponsiveListItemRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string + navigationEndpoint?: { + watchEndpoint: { + videoId: string + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC' + } + } + } + } + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string // Duration will be in one of these + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL' + } + } + } + } + }> + } + } + }, + ] + } + + type AlbumMusicResponsiveListItemRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string // Release year will be in one of these + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + }> + } + } + }, + ] + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' + } + } + } + } + } + + type CommunityPlaylistMusicResponsiveListItemRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL' + } + } + } + } + }> + } + } + }, + ] + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_PLAYLIST' + } + } + } + } + } + + type ArtistMusicResponsiveListItemRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + // Nothing Useful in here + text: string + }> + } + } + }, + ] + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + } + + type EpisodeMusicResponsiveListItemRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string + navigationEndpoint: { + browseEndpoint: { + browseId: string // This is the id to get to the episode's page + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_NON_MUSIC_AUDIO_TRACK_PAGE' + } + } + } + } + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE' + } + } + } + } + }> + } + } + }, + ] + playlistItemData: { + videoId: string // This is the id to actually play the video + } + } + + type SongMusicCardShelfRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + title: { + runs: [ + { + text: string + navigationEndpoint: { + watchEndpoint: { + videoId: string + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_ATV' + } + } + } + } + }, + ] + } + subtitle: { + runs: Array<{ + text: string // The duration string will also be in here + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' | 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + }> + } + contents?: Array< + | { + messageRenderer: unknown + } + | { + musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer | VideoMusicResponsiveListItemRenderer // I'm not sure if any other types can appear in these + } + > + } + + type VideoMusicCardShelfRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + title: { + runs: [ + { + text: string + navigationEndpoint: { + watchEndpoint: { + videoId: string + watchEndpointMusicSupportedConfigs: { + watchEndpointMusicConfig: { + musicVideoType: 'MUSIC_VIDEO_TYPE_OMV' | 'MUSIC_VIDEO_TYPE_UGC' | 'MUSIC_VIDEO_TYPE_OFFICIAL_SOURCE_MUSIC' + } + } + } + } + }, + ] + } + subtitle: { + runs: Array<{ + text: string // The duration string will also be in here + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' | 'MUSIC_PAGE_TYPE_USER_CHANNEL' + } + } + } + } + }> + } + } + + type AlbumMusicCardShelfRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + title: { + runs: [ + { + text: string + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ALBUM' + } + } + } + } + }, + ] + } + subtitle: { + runs: Array<{ + text: string // "Various Artists" may take place of any run with a navigation endpoint + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + }> + } + } + + type ArtistMusicCardShelfRenderer = { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + title: { + runs: [ + { + text: string + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_ARTIST' + } + } + } + } + }, + ] + } + // Nothing in the subtitle is useful + contents: Array<{ + // I have yet to run into a scenario where an artist card did not have contents + musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer + }> + } + + type SongsMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Songs' + }, + ] + } + contents: Array<{ + musicResponsiveListItemRenderer: SongMusicResponsiveListItemRenderer + }> + } + + type VideosMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Videos' + }, + ] + } + contents: Array<{ + // For some reason episodes can sometimes show up video sections. Because why have any sort of consistency in your app? FML + musicResponsiveListItemRenderer: VideoMusicResponsiveListItemRenderer | EpisodeMusicResponsiveListItemRenderer + }> + } + + type AlbumsMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Albums' + }, + ] + } + contents: Array<{ + musicResponsiveListItemRenderer: AlbumMusicResponsiveListItemRenderer + }> + } + + type CommunityPlaylistsMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Community playlists' + }, + ] + } + contents: Array<{ + musicResponsiveListItemRenderer: CommunityPlaylistsMusicResponsiveListItemRenderer + }> + } + + type ArtistsMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Artists' + }, + ] + } + contents: Array<{ + musicResponsiveListItemRenderer: ArtistMusicResponsiveListItemRenderer + }> + } + + type PodcastsMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Podcasts' + }, + ] + } + contents: Array<{ + musicResponsiveListItemRenderer: { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string + navigationEndpoint?: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL' + } + } + } + } + }> + } + } + }, + ] + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_PODCAST_SHOW_DETAIL_PAGE' + } + } + } + } + } + }> + } + + type EpisodesMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Episodes' + }, + ] + } + contents: Array<{ + musicResponsiveListItemRenderer: EpisodeMusicResponsiveListItemRenderer + }> + } + + type ProfilesMusicShelfRenderer = { + title: { + runs: [ + { + text: 'Profiles' + }, + ] + } + contents: Array<{ + musicResponsiveListItemRenderer: { + thumbnail: { + musicThumbnailRenderer: { + thumbnail: { + thumbnails: Array<{ + url: string + width: number + height: number + }> + } + } + } + flexColumns: [ + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: [ + { + text: string + }, + ] + } + } + }, + { + musicResponsiveListItemFlexColumnRenderer: { + text: { + runs: Array<{ + text: string + }> + } + } + }, + ] + navigationEndpoint: { + browseEndpoint: { + browseId: string + browseEndpointContextSupportedConfigs: { + browseEndpointContextMusicConfig: { + pageType: 'MUSIC_PAGE_TYPE_USER_CHANNEL' + } + } + } + } + } + }> + } } // TODO: Need to fix this & it's corresponding method & add appropriate namespace @@ -854,3 +1647,60 @@ export namespace InnerTube { contents: unknown } } + +export namespace YouTubeDataApi { + namespace PlaylistItems { + type Response

= { + nextPageToken?: string + prevPageToken?: string + items: Array> + pageInfo: { + totalResults: number + resultsPerPage: number + } + } + + type Item

= { + id: string + } & { + [K in P]: K extends 'snippet' ? Snippet : K extends 'contentDetails' ? ContentDetails : K extends 'status' ? Status : never + } + + type Snippet = { + publishedAt: string + channelId: string + title: string + description: string + thumbnails: { + default: Thumbnail + medium: Thumbnail + high: Thumbnail + standard?: Thumbnail + maxres?: Thumbnail + } + channelTitle: string + playlistId: string + position: number + resourceId: { + videoId: string + } + videoOwnerChannelTitle?: string + videoOwnerChannelId?: string + } + + type ContentDetails = { + videoId: string + videoPublishedAt: string + } + + type Status = { + privacyStatus: string + } + + type Thumbnail = { + url: string + width: number + height: number + } + } +} diff --git a/src/lib/server/youtube-music.ts b/src/lib/server/youtube-music.ts index 581415f..5bc0177 100644 --- a/src/lib/server/youtube-music.ts +++ b/src/lib/server/youtube-music.ts @@ -1,44 +1,40 @@ -import { youtube, type youtube_v3 } from 'googleapis/build/src/apis/youtube' 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 type { InnerTube, YouTubeDataApi } from './youtube-music-types' import { DB } from './db' +import ky, { type KyInstance } from 'ky' -const ytDataApi = youtube('v3') // TODO: At some point I want to ditch this package and just make the API calls directly. Fewer dependecies - -// TODO: Throughout this method, whenever I extract the duration of a video I might want to subtract 1, the actual duration appears to always be one second less than what the duration lists. export class YouTubeMusic implements Connection { public readonly id: string private readonly userId: string private readonly youtubeUserId: string - private readonly requestManager: YTRequestManager - private libraryManager?: YTLibaryManager + private readonly api: APIManager + private libraryManager?: LibaryManager constructor(id: string, userId: string, youtubeUserId: string, accessToken: string, refreshToken: string, expiry: number) { this.id = id this.userId = userId this.youtubeUserId = youtubeUserId - this.requestManager = new YTRequestManager(id, accessToken, refreshToken, expiry) + this.api = new APIManager(id, accessToken, refreshToken, expiry) } public get library() { - if (!this.libraryManager) this.libraryManager = new YTLibaryManager(this.id, this.youtubeUserId, this.requestManager) + if (!this.libraryManager) this.libraryManager = new LibaryManager(this.id, this.youtubeUserId, this.api) return this.libraryManager } + // * This method can NOT throw an error public async getConnectionInfo() { - const access_token = await this.requestManager.accessToken.catch(() => null) + const response = await this.api.v1 + .WEB_REMIX('browse', { json: { browseId: this.youtubeUserId } }) + .json() + .catch(() => null) - let username: string | undefined, profilePicture: string | undefined - if (access_token) { - const userChannelResponse = await ytDataApi.channels.list({ mine: true, part: ['snippet'], access_token }) - const userChannel = userChannelResponse?.data.items?.[0] - username = userChannel?.snippet?.title ?? undefined - profilePicture = userChannel?.snippet?.thumbnails?.default?.url ?? undefined - } + const username = response?.header.musicVisualHeaderRenderer.title.runs[0].text + const profilePicture = response ? extractLargestThumbnailUrl(response.header.musicVisualHeaderRenderer.foregroundThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined return { id: this.id, @@ -50,29 +46,265 @@ export class YouTubeMusic implements Connection { } satisfies ConnectionInfo } - // ! Need to completely rework this method - Currently returns empty array - public async search(searchTerm: string, filter: 'song'): Promise - public async search(searchTerm: string, filter: 'album'): Promise - public async search(searchTerm: string, filter: 'artist'): Promise - public async search(searchTerm: string, filter: 'playlist'): Promise - public async search(searchTerm: string, filter?: undefined): Promise<(Song | Album | Artist | Playlist)[]> - public async search(searchTerm: string, filter?: 'song' | 'album' | 'artist' | 'playlist'): Promise<(Song | Album | Artist | Playlist)[]> { + public async search(searchTerm: string, types: Set): Promise { const searchFilterParams = { song: 'EgWKAQIIAWoMEA4QChADEAQQCRAF', + video: 'EgWKAQIQAWoMEA4QChADEAQQCRAF', album: 'EgWKAQIYAWoMEA4QChADEAQQCRAF', artist: 'EgWKAQIgAWoMEA4QChADEAQQCRAF', - playlist: 'Eg-KAQwIABAAGAAgACgBMABqChAEEAMQCRAFEAo%3D', + playlist: 'EgeKAQQoAEABagwQDhAKEAMQBBAJEAU%3D', } as const - return [] // /search && { body: { query: searchTerm, params: searchFilterParams[filter] } } + const searchType = async (type: keyof typeof searchFilterParams) => this.api.v1.WEB_REMIX('search', { json: { query: searchTerm, params: searchFilterParams[type] } }).json() + + const extendedTypes = new Set(types) + if (extendedTypes.has('song')) extendedTypes.add('video') + + const searchResponses = await Promise.all(Array.from(extendedTypes, searchType)) + + // Ok so I have a problem here. Firstly, the youtube music flavor of search is fucking abyssmal. You get like at most 3 results for each type of + // content and most of it is completely irrelavent and not even close to what you search for. On top of that it won't even try to return + // livestreams or past completed broadcasts. The standard youtube search is far superior however the pain point there is you are either getting + // a video (which includes livestream content), a channel, or a playlist, which does not line up super well with the current Song, Album, Artist + // Playlist architecture. For non-livestream videos I could put the results throught the getSongs() method which will scrape the counterparts, + // but there is really no way to get albums or or determine if a channel is an artist or an uploader. I guess I could query both with filters but + // the minimum of like five API calls + the parsing just sounds like such an bad time. + + // Now that I think about it, I don't really know how I want to do search. Returning finite results would make my life easier but I think that's + // just not a good idea. Acutally it seems most streaming services do limit the number of seach results. Only problem is, IDK how I'm supposed to + // implement a limt query param in the search endpoint when the I'm getting all of the content from many different APIs, *some of which* (fucking yt music), + // don't provide a way to limit the number of results returned + + // Holy fuck it gets even worse. The v3 API does not even allow you get anything beyond the "snippet" for completed live streams, meaning no duration or high res + // thumbnails (at most it returns like a 360p). On top of that we can't use the getSongs() method either because it will return an error response (INVALID_ARGUMENT). + // I think I'm just going to have to bite the bullet query both the YTMusic API and the standard youtube search v1 API. + + // NOTE: + // To ensure best result we want to make sure we are only getting videos relavent to the search back. Youtube for some reason throws so much bs in their default search results + // like "For You" and "People also Watched", which is almost never relevant to the actual search. To fix this just include the param EgIQAQ%3D%3D in the body of the request. + // This way it should only return a list of videos that are relevant to the actual search, including past completed broadcasts and excluding active livestream (which we want). + // Also, don't try to get playlists from the default search, we want to get those from the YTMusic API so we get those nice 2x2 album art thumbnails + + // Brand new problems. With the standard YT Video search there is no way to determine if the channel that uploaded it is an artist or just an uploader channel. + // Additionally, ytmusic appears to try to filter results for videos that are music oriented. This does not always work perfectly, especially if you search for something + // inherently non-musical, but it means that generally results are more likely to be music related than with the standard YT search. + + // Y'know what, I'm just not going to worry about this now. + + const parseSongAndVideoCardShelfRenderer = (card: InnerTube.Search.SongMusicCardShelfRenderer | InnerTube.Search.VideoMusicCardShelfRenderer): Song => { + const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] + const id = card.title.runs[0].navigationEndpoint.watchEndpoint.videoId + const name = card.title.runs[0].text + const isVideo = card.title.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV' + const duration = timestampToSeconds(card.subtitle.runs.find((run) => /^(\d{1,}:\d{2}:\d{2}|\d{1,2}:\d{2})$/.test(run.text))!.text) + const thumbnailUrl = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) + + let artists: Song['artists'], album: Song['album'], uploader: Song['uploader'] + card.subtitle.runs.forEach((run) => { + if (!run.navigationEndpoint) return + + const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + switch (pageType) { + case 'MUSIC_PAGE_TYPE_ALBUM': + album = runData + break + case 'MUSIC_PAGE_TYPE_ARTIST': + artists ? artists.push(runData) : (artists = [runData]) + break + case 'MUSIC_PAGE_TYPE_USER_CHANNEL': + uploader = runData + break + } + }) + + return { connection, id, name, type: 'song', duration, thumbnailUrl, artists, album, uploader, isVideo } + } + + // Returns null if the video is not playable or if it is an Episode (not currently supported) + // ? The videos filter for YTMusic search only returns sddefault images at most. Might just want to scrape the id an then use getSongs() + const parseSongAndVideoResponsiveListItemRenderer = ( + item: InnerTube.Search.SongMusicResponsiveListItemRenderer | InnerTube.Search.VideoMusicResponsiveListItemRenderer | InnerTube.Search.EpisodeMusicResponsiveListItemRenderer, + ): Song | null => { + const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] + const col1 = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0] + const col2runs = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs + + if (!col1.navigationEndpoint || 'browseEndpoint' in col1.navigationEndpoint) return null + + const id = col1.navigationEndpoint.watchEndpoint.videoId + const name = col1.text + const isVideo = col1.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV' + const duration = timestampToSeconds(col2runs.find((run) => /^(\d{1,}:\d{2}:\d{2}|\d{1,2}:\d{2})$/.test(run.text))!.text) + const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) + + let artists: Song['artists'], album: Song['album'], uploader: Song['uploader'] + col2runs.forEach((run) => { + if (!run.navigationEndpoint) return + + const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + const runData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + switch (pageType) { + case 'MUSIC_PAGE_TYPE_ALBUM': + album = runData + break + case 'MUSIC_PAGE_TYPE_ARTIST': + artists ? artists.push(runData) : (artists = [runData]) + break + case 'MUSIC_PAGE_TYPE_USER_CHANNEL': + uploader = runData + break + } + }) + + return { connection, id, name, type: 'song', duration, thumbnailUrl, artists, album, uploader, isVideo } + } + + const parseAlbumCardShelfRenderer = (card: InnerTube.Search.AlbumMusicCardShelfRenderer): Album => { + const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection'] + const id = card.title.runs[0].navigationEndpoint.browseEndpoint.browseId + const name = card.title.runs[0].text + const thumbnailUrl = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) + + let artists: Album['artists'] = 'Various Artists', + releaseYear: string | undefined + + card.subtitle.runs.forEach((run) => { + if (run.navigationEndpoint) { + const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData) + } else if (/^\d{4}$/.test(run.text)) { + releaseYear = run.text + } + }) + + return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } + } + + const parseArtistCardShelfRenderer = (card: InnerTube.Search.ArtistMusicCardShelfRenderer): Artist => { + const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection'] + const id = card.title.runs[0].navigationEndpoint.browseEndpoint.browseId + const name = card.title.runs[0].text + const profilePicture = extractLargestThumbnailUrl(card.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) + + return { connection, id, name, type: 'artist', profilePicture } + } + + const parseAlbumResponsiveListItemRenderer = (item: InnerTube.Search.AlbumMusicResponsiveListItemRenderer): Album => { + const connection = { id: this.id, type: 'youtube-music' } satisfies Album['connection'] + const id = item.navigationEndpoint.browseEndpoint.browseId + const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text + const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) + + let artists: Album['artists'] = 'Various Artists', + releaseYear: Album['releaseYear'] + + item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => { + if (run.navigationEndpoint) { + const artistData = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + typeof artists === 'string' ? (artists = [artistData]) : artists.push(artistData) + } else if (/^\d{4}$/.test(run.text)) { + releaseYear = run.text + } + }) + + return { connection, id, name, type: 'album', thumbnailUrl, artists, releaseYear } + } + + const parseArtistResponsiveListItemRenderer = (item: InnerTube.Search.ArtistMusicResponsiveListItemRenderer): Artist => { + const connection = { id: this.id, type: 'youtube-music' } satisfies Artist['connection'] + const id = item.navigationEndpoint.browseEndpoint.browseId + const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text + const profilePicture = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) + + return { connection, id, name, type: 'artist', profilePicture } + } + + const parseCommunityPlaylistResponsiveListItemRenderer = (item: InnerTube.Search.CommunityPlaylistMusicResponsiveListItemRenderer): Playlist => { + const connection = { id: this.id, type: 'youtube-music' } satisfies Playlist['connection'] + const id = item.navigationEndpoint.browseEndpoint.browseId + const name = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].text + const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) + + let createdBy: Playlist['createdBy'] + item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => { + if (!run.navigationEndpoint) return + + createdBy = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } + }) + + return { connection, id, name, type: 'playlist', thumbnailUrl, createdBy } + } + + const contents = searchResponses.map((response) => response.contents.tabbedSearchResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents).flat() + + const cardSections = contents.filter((section) => 'musicCardShelfRenderer' in section) + const shelveSections = contents.filter((section) => 'musicShelfRenderer' in section) + + const extractedItems: (Song | Album | Artist | Playlist)[] = [] + + for (const section of cardSections) { + if ('watchEndpoint' in section.musicCardShelfRenderer.title.runs[0].navigationEndpoint) { + const card = section.musicCardShelfRenderer as InnerTube.Search.SongMusicCardShelfRenderer | InnerTube.Search.VideoMusicCardShelfRenderer + extractedItems.push(parseSongAndVideoCardShelfRenderer(card)) + + if (!('contents' in card && card.contents)) continue + + const playableContents = card.contents.filter((item) => 'musicResponsiveListItemRenderer' in item) + const contentSongs = playableContents.map((item) => parseSongAndVideoResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)).filter((song) => song !== null) + extractedItems.push(...contentSongs) + } else { + const sectionType = section.musicCardShelfRenderer.title.runs[0].navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType + if (sectionType === 'MUSIC_PAGE_TYPE_ALBUM') { + const card = section.musicCardShelfRenderer as InnerTube.Search.AlbumMusicCardShelfRenderer + extractedItems.push(parseAlbumCardShelfRenderer(card)) + } else { + const card = section.musicCardShelfRenderer as InnerTube.Search.ArtistMusicCardShelfRenderer + card.contents.forEach((content) => { + const song = parseSongAndVideoResponsiveListItemRenderer(content.musicResponsiveListItemRenderer) + if (song) extractedItems.push(song) + }) + extractedItems.push(parseArtistCardShelfRenderer(card)) + } + } + } + + for (const section of shelveSections) { + switch (section.musicShelfRenderer.title.runs[0].text) { + case 'Songs': + case 'Videos': + const songShelf = section.musicShelfRenderer as InnerTube.Search.SongsMusicShelfRenderer | InnerTube.Search.VideosMusicShelfRenderer + const songs = songShelf.contents.map((item) => parseSongAndVideoResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)).filter((song) => song !== null) + extractedItems.push(...songs) + break + case 'Albums': + const albumShelf = section.musicShelfRenderer as InnerTube.Search.AlbumsMusicShelfRenderer + const albums = albumShelf.contents.map((item) => parseAlbumResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) + extractedItems.push(...albums) + break + case 'Artists': + const artistShelf = section.musicShelfRenderer as InnerTube.Search.ArtistsMusicShelfRenderer + const artists = artistShelf.contents.map((item) => parseArtistResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) + extractedItems.push(...artists) + break + case 'Community playlists': + const playlistShelf = section.musicShelfRenderer as InnerTube.Search.CommunityPlaylistsMusicShelfRenderer + const playlists = playlistShelf.contents.map((item) => parseCommunityPlaylistResponsiveListItemRenderer(item.musicResponsiveListItemRenderer)) + extractedItems.push(...playlists) + break + } + } + + return extractedItems.filter((item): item is MediaItemTypeMap[T] => types.has(item.type as T)) } // ! Need to completely rework this method - Currently returns empty array public async getRecommendations() { - return [] // browseId: 'FEmusic_home' + // const response = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_home' } }).json() + // console.log(JSON.stringify(response)) + return [] } - // TODO: Move to innerTubeFetch method public async getAudioStream(id: string, headers: Headers) { if (!isValidVideoId(id)) throw TypeError('Invalid youtube video Id') @@ -87,35 +319,7 @@ export class YouTubeMusic implements Connection { // * MASSIVE props and credit to Oleksii Holub for documenting the android client method of player fetching (See refrences at bottom). // * Go support him and go support Ukraine (he's Ukrainian) - const playerResponse = await fetch('https://www.youtube.com/youtubei/v1/player', { - headers: { - // 'user-agent': 'com.google.android.youtube/17.36.4 (Linux; U; Android 12; GB) gzip', <-- I thought this was necessary but it appears it might not be? - authorization: `Bearer ${await this.requestManager.accessToken}`, // * Including the access token is what enables access to premium content for some reason - }, - method: 'POST', - body: JSON.stringify({ - videoId: id, - context: { - client: { - clientName: 'ANDROID_TESTSUITE', - clientVersion: '1.9', - // androidSdkVersion: 30, <-- I thought this was necessary but it appears it might not be? - }, - }, - }), - }) - .then((response) => response.json() as Promise) - .catch(() => null) - - if (!playerResponse) throw Error(`Failed to fetch player for song ${id} of connection ${this.id}`) - - if (!('streamingData' in playerResponse)) { - if (playerResponse.playabilityStatus.reason === 'This video is unavailable') throw TypeError('Invalid youtube video Id') - - const errorMessage = `Unknown player response error: ${playerResponse.playabilityStatus.reason}` - console.error(errorMessage) - throw Error(errorMessage) - } + const playerResponse = await this.api.v1.ANDROID_TESTSUITE('player', { json: { videoId: id } }).json() const formats = playerResponse.streamingData.formats?.concat(playerResponse.streamingData.adaptiveFormats ?? []) const audioOnlyFormats = formats?.filter( @@ -142,20 +346,7 @@ export class YouTubeMusic implements Connection { * @param id The browseId of the album */ public async getAlbum(id: string): Promise { - const albumResponse = await this.requestManager - .innerTubeFetch('/browse', { body: { browseId: id } }) - .then((response) => response.json() as Promise) - .catch(() => null) - - if (!albumResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`) - - if ('error' in albumResponse) { - if (albumResponse.error.status === 'NOT_FOUND' || albumResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube album id') - - const errorMessage = `Unknown playlist response error: ${albumResponse.error.message}` - console.error(errorMessage) - throw Error(errorMessage) - } + const albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json() const header = albumResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer @@ -182,122 +373,28 @@ export class YouTubeMusic implements Connection { * @param id The browseId of the album */ public async getAlbumItems(id: string): Promise { - const albumResponse = await this.requestManager - .innerTubeFetch('/browse', { body: { browseId: id } }) - .then((response) => response.json() as Promise) - .catch(() => null) - - if (!albumResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`) - - if ('error' in albumResponse) { - if (albumResponse.error.status === 'NOT_FOUND' || albumResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube album id') - - const errorMessage = `Unknown playlist response error: ${albumResponse.error.message}` - console.error(errorMessage) - throw Error(errorMessage) - } - - const header = albumResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicResponsiveHeaderRenderer + const albumResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: id } }).json() const contents = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicShelfRenderer.contents let continuation = albumResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.continuations?.[0].nextContinuationData.continuation - const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] - const thumbnailUrl = extractLargestThumbnailUrl(header.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) - const album: Song['album'] = { id, name: header.title.runs[0].text } - - const artistMap = new Map() - header.straplineTextOne.runs.forEach((run, index) => { - if (run.navigationEndpoint) { - const profilePicture = index === 0 && header.straplineThumbnail ? extractLargestThumbnailUrl(header.straplineThumbnail.musicThumbnailRenderer.thumbnail.thumbnails) : undefined - artistMap.set(run.navigationEndpoint.browseEndpoint.browseId, { name: run.text, profilePicture }) - } - }) - - const albumArtists = Array.from(artistMap, (artist) => ({ id: artist[0], name: artist[1].name, profilePicture: artist[1].profilePicture })) - while (continuation) { - const continuationResponse = await this.requestManager - .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) - .then((response) => response.json() as Promise) - .catch(() => null) - - if (!continuationResponse) throw Error(`Failed to fetch album ${id} of connection ${this.id}`) + const continuationResponse = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json() contents.push(...continuationResponse.continuationContents.musicShelfRenderer.contents) continuation = continuationResponse.continuationContents.musicShelfRenderer.continuations?.[0].nextContinuationData.continuation } - // Just putting this here in the event that for some reason an album has non-playlable items, never seen it happen but couldn't hurt - const playableItems = contents.filter((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined) + const playableIds = contents.map((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId) - const dividedItems = [] - for (let i = 0; i < playableItems.length; i += 50) dividedItems.push(playableItems.slice(i, i + 50)) - - const access_token = await this.requestManager.accessToken - const videoSchemas = await Promise.all( - dividedItems.map((chunk) => - ytDataApi.videos.list({ - part: ['snippet'], - id: chunk.map((item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId), - access_token, - }), - ), - ).then((responses) => responses.map((response) => response.data.items!).flat()) - - const descriptionRelease = videoSchemas.find((video) => video.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] !== undefined)?.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] - const releaseDate = new Date(descriptionRelease ?? header.subtitle.runs.at(-1)?.text!).toISOString() - - const videoChannelMap = new Map() - videoSchemas.forEach((video) => videoChannelMap.set(video.id!, { id: video.snippet?.channelId!, name: video.snippet?.channelTitle! })) - - return playableItems.map((item) => { - const [col0, col1] = item.musicResponsiveListItemRenderer.flexColumns - - const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.videoId - const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text - - const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType - const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV' - - const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text) - - let artists: Song['artists'] - if (!col1.musicResponsiveListItemFlexColumnRenderer.text.runs) { - artists = albumArtists - } else { - col1.musicResponsiveListItemFlexColumnRenderer.text.runs.forEach((run) => { - if (run.navigationEndpoint) { - const artist = { id: run.navigationEndpoint.browseEndpoint.browseId, name: run.text } - artists ? artists.push(artist) : (artists = [artist]) - } - }) - } - - const uploader: Song['uploader'] = artists ? undefined : videoChannelMap.get(id)! - - return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, uploader, isVideo } - }) + return this.getSongs(playableIds) } /** * @param id The id of the playlist (not the browseId!). */ public async getPlaylist(id: string): Promise { - const playlistResponse = await this.requestManager - .innerTubeFetch('/browse', { body: { browseId: 'VL'.concat(id) } }) - .then((response) => response.json() as Promise) - .catch(() => null) - - if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`) - - if ('error' in playlistResponse) { - if (playlistResponse.error.status === 'NOT_FOUND' || playlistResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube playlist id') - - const errorMessage = `Unknown playlist response error: ${playlistResponse.error.message}` - console.error(errorMessage) - throw Error(errorMessage) - } + const playlistResponse = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'VL'.concat(id) } }).json() const header = 'musicEditablePlaylistDetailHeaderRenderer' in playlistResponse.contents.twoColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0] @@ -327,141 +424,71 @@ export class YouTubeMusic implements Connection { * @param limit The maximum number of playlist items to return */ public async getPlaylistItems(id: string, options?: { startIndex?: number; limit?: number }): Promise { - const startIndex = options?.startIndex, - limit = options?.limit + const startIndex = options?.startIndex ?? 0, + limit = options?.limit ?? Infinity - const playlistResponse = await this.requestManager - .innerTubeFetch('/browse', { body: { browseId: 'VL'.concat(id) } }) - .then((response) => response.json() as Promise) - .catch(() => null) + const playlistItemSearchParams = new URLSearchParams({ + playlistId: id, + maxResults: '50', + part: 'snippet,contentDetails,status', + }) - if (!playlistResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`) + const playableItems: YouTubeDataApi.PlaylistItems.Item<'snippet' | 'contentDetails' | 'status'>[] = [] + while (playableItems.length < startIndex + limit) { + const itemsResponse = await this.api.v3(`playlistItems?${playlistItemSearchParams.toString()}`).json>() - if ('error' in playlistResponse) { - if (playlistResponse.error.status === 'NOT_FOUND' || playlistResponse.error.status === 'INVALID_ARGUMENT') throw TypeError('Invalid youtube playlist id') + playableItems.push(...itemsResponse.items.filter((item) => item.status.privacyStatus === 'public' || item.snippet.videoOwnerChannelId === item.snippet.channelId)) - const errorMessage = `Unknown playlist items response error: ${playlistResponse.error.message}` - console.error(errorMessage) - throw Error(errorMessage) + if (!itemsResponse.nextPageToken) break // Reached the end of the playlist, retrieved all items + + playlistItemSearchParams.set('pageToken', itemsResponse.nextPageToken) } - const playableContents = playlistResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.contents.filter( - (item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined, + const slicedItems = playableItems.slice(startIndex, startIndex + limit) // Removes over-fetch + + const releaseDateMap = new Map() + slicedItems.forEach((item) => + releaseDateMap.set(item.contentDetails.videoId, new Date(item.snippet.description.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? item.contentDetails.videoPublishedAt).toISOString()), ) - let continuation = playlistResponse.contents.twoColumnBrowseResultsRenderer.secondaryContents.sectionListRenderer.contents[0].musicPlaylistShelfRenderer.continuations?.[0].nextContinuationData.continuation + const songs = await this.getSongs(releaseDateMap.keys()) + songs.forEach((song) => (song.releaseDate = releaseDateMap.get(song.id))) - while (continuation && (!limit || playableContents.length < (startIndex ?? 0) + limit)) { - const continuationResponse = await this.requestManager - .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) - .then((response) => response.json() as Promise) - .catch(() => null) - - if (!continuationResponse) throw Error(`Failed to fetch playlist ${id} of connection ${this.id}`) - - const playableContinuationContents = continuationResponse.continuationContents.musicPlaylistShelfContinuation.contents.filter( - (item) => item.musicResponsiveListItemRenderer.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint?.watchEndpoint?.videoId !== undefined, - ) - - playableContents.push(...playableContinuationContents) - continuation = continuationResponse.continuationContents.musicPlaylistShelfContinuation.continuations?.[0].nextContinuationData.continuation - } - - const scrapedItems = playableContents.slice(startIndex ?? 0, limit ? (startIndex ?? 0) + limit : undefined).map((item) => { - const [col0, col1, col2] = item.musicResponsiveListItemRenderer.flexColumns - - const id = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.videoId - const name = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text - - const videoType = col0.musicResponsiveListItemFlexColumnRenderer.text.runs[0].navigationEndpoint!.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType - const isVideo = videoType !== 'MUSIC_VIDEO_TYPE_ATV' - - const thumbnailUrl = isVideo ? undefined : extractLargestThumbnailUrl(item.musicResponsiveListItemRenderer.thumbnail.musicThumbnailRenderer.thumbnail.thumbnails) - const duration = timestampToSeconds(item.musicResponsiveListItemRenderer.fixedColumns[0].musicResponsiveListItemFixedColumnRenderer.text.runs[0].text) - - const col2run = col2.musicResponsiveListItemFlexColumnRenderer.text.runs?.[0] - const album: Song['album'] = col2run ? { id: col2run.navigationEndpoint.browseEndpoint.browseId, name: col2run.text } : undefined - - let artists: { id?: string; name: string }[] | undefined = [], - uploader: { id?: string; name: string } | undefined - - for (const run of col1.musicResponsiveListItemFlexColumnRenderer.text.runs) { - const pageType = run.navigationEndpoint?.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType - const runData = { id: run.navigationEndpoint?.browseEndpoint.browseId, name: run.text } - - pageType === 'MUSIC_PAGE_TYPE_ARTIST' ? artists.push(runData) : (uploader = runData) - } - - if (artists.length === 0) artists = undefined - - return { id, name, duration, thumbnailUrl, artists, album, uploader, isVideo } - }) - - const dividedItems = [] - for (let i = 0; i < scrapedItems.length; i += 50) dividedItems.push(scrapedItems.slice(i, i + 50)) - - const access_token = await this.requestManager.accessToken - const videoSchemaMap = new Map() - const videoSchemas = (await Promise.all(dividedItems.map((chunk) => ytDataApi.videos.list({ part: ['snippet'], id: chunk.map((item) => item.id), access_token })))).map((response) => response.data.items!).flat() - videoSchemas.forEach((schema) => videoSchemaMap.set(schema.id!, schema)) - - const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] - return scrapedItems.map((item) => { - const correspondingSchema = videoSchemaMap.get(item.id)! - const { id, name, duration, album, isVideo } = item - const existingThumbnail = item.thumbnailUrl - const artists = item.artists?.map((artist) => ({ id: artist.id ?? correspondingSchema.snippet?.channelId!, name: artist.name })) - const uploader = item.uploader ? { id: item.uploader?.id ?? correspondingSchema.snippet?.channelId!, name: item.uploader.name } : undefined - - const videoThumbnails = correspondingSchema.snippet?.thumbnails! - - const thumbnailUrl = existingThumbnail ?? videoThumbnails.maxres?.url ?? videoThumbnails.standard?.url ?? videoThumbnails.high?.url ?? videoThumbnails.medium?.url ?? videoThumbnails.default?.url! - const releaseDate = new Date(correspondingSchema.snippet?.description?.match(/Released on: \d{4}-\d{2}-\d{2}/)?.[0] ?? correspondingSchema.snippet?.publishedAt!).toISOString() - - return { connection, id, name, type: 'song', duration, thumbnailUrl, releaseDate, artists, album, uploader, isVideo } satisfies Song - }) + return songs } /** - * @param ids An array of youtube video ids. - * @throws Error if the fetch failed. TypeError if an invalid videoId was included in the request. + * @param {Iterable} ids An iterable of youtube video ids. Duplicate ids will be filtered out + * @returns {Promise} An array of Songs. Unavailable songs/videos will be filtered out. */ - // ? So far don't know if there is a cap for how many you can request a once. My entire 247 song J-core playlist worked in one request no problem. - // ? The only thing this method is really missing is release dates, which would be the easiest thing to get from the v3 API, but I'm struggling to - // ? justify making those requests just for the release date. Maybe I can justify it if I find other data in the v3 API that would be useful. - public async getSongs(ids: string[]): Promise { - if (ids.some((id) => !isValidVideoId(id))) throw TypeError('Invalid video id in request') + public async getSongs(ids: Iterable): Promise { + const uniqueIds = new Set(ids) - const response = await this.requestManager - .innerTubeFetch('/queue', { body: { videoIds: ids } }) - .then((response) => response.json() as Promise) - .catch(() => null) + const response = await this.api.v1.WEB_REMIX('music/get_queue', { json: { videoIds: Array.from(uniqueIds) } }).json() - if (!response) throw Error(`Failed to fetch ${ids.length} songs from connection ${this.id}`) + const items = response.queueDatas + .map((item) => { + // If song has both an ATV 'counterpart' and video, this will chose whichever matches the id provided in the request + if ('playlistPanelVideoRenderer' in item.content) return item.content.playlistPanelVideoRenderer - if ('error' in response) { - if (response.error.status === 'NOT_FOUND') throw TypeError('Invalid video id in request') + const primaryRenderer = item.content.playlistPanelVideoWrapperRenderer.primaryRenderer.playlistPanelVideoRenderer + if (uniqueIds.has(primaryRenderer.videoId)) return primaryRenderer - const errorMessage = `Unknown playlist items response error: ${response.error.message}` - console.error(errorMessage, response.error.status, response.error.code) - throw Error(errorMessage) - } + return item.content.playlistPanelVideoWrapperRenderer.counterpart[0].counterpartRenderer.playlistPanelVideoRenderer + }) + .filter((item) => 'title' in item) // TODO: Add indication that some results were filtered out - return response.queueDatas.map((item) => { - // ? When the song has both a video and auto-generated version, currently I have it set to choose the 'counterpart' auto-generated version as they usually have more complete data, - // ? as well as the benefit of scalable thumbnails. However, In the event the video versions actually do provide something of value, maybe scrape both. - const itemData = - 'playlistPanelVideoRenderer' in item.content ? item.content.playlistPanelVideoRenderer : item.content.playlistPanelVideoWrapperRenderer.counterpart[0].counterpartRenderer.playlistPanelVideoRenderer + return items.map((item) => { const connection = { id: this.id, type: 'youtube-music' } satisfies Song['connection'] - const id = itemData.videoId - const name = itemData.title.runs[0].text - const duration = timestampToSeconds(itemData.lengthText.runs[0].text) - const thumbnailUrl = extractLargestThumbnailUrl(itemData.thumbnail.thumbnails) + const id = item.videoId + const name = item.title.runs[0].text + const duration = timestampToSeconds(item.lengthText.runs[0].text) + const thumbnailUrl = extractLargestThumbnailUrl(item.thumbnail.thumbnails) const artists: Song['artists'] = [] let album: Song['album'] let uploader: Song['uploader'] - itemData.longBylineText.runs.forEach((run) => { + item.longBylineText.runs.forEach((run) => { if (!run.navigationEndpoint) return const pageType = run.navigationEndpoint.browseEndpoint.browseEndpointContextSupportedConfigs.browseEndpointContextMusicConfig.pageType @@ -475,31 +502,79 @@ export class YouTubeMusic implements Connection { } }) - const isVideo = itemData.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV' + const isVideo = item.navigationEndpoint.watchEndpoint.watchEndpointMusicSupportedConfigs.watchEndpointMusicConfig.musicVideoType !== 'MUSIC_VIDEO_TYPE_ATV' return { connection, id, name, type: 'song', duration, thumbnailUrl, artists: artists.length > 0 ? artists : undefined, album, uploader, isVideo } satisfies Song }) } } -class YTRequestManager { +class APIManager { private readonly connectionId: string private currentAccessToken: string private readonly refreshToken: string private expiry: number + public readonly v1: { + WEB_REMIX: KyInstance + ANDROID_TESTSUITE: KyInstance + } + public readonly v3: KyInstance + constructor(connectionId: string, accessToken: string, refreshToken: string, expiry: number) { this.connectionId = connectionId this.currentAccessToken = accessToken this.refreshToken = refreshToken this.expiry = expiry + + const authHook = async (request: Request) => request.headers.set('authorization', `Bearer ${await this.accessToken}`) + + const baseV1 = ky.create({ + prefixUrl: 'https://music.youtube.com/youtubei/v1/', + method: 'post', + hooks: { beforeRequest: [authHook] }, + }) + + const WEB_REMIX = baseV1.extend({ + json: { + context: { + client: { + clientName: 'WEB_REMIX', + get clientVersion() { + 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') + + return `1.${year + month + day}.01.00` + }, + }, + }, + }, + }) + + const ANDROID_TESTSUITE = baseV1.extend({ + json: { + context: { + client: { + clientName: 'ANDROID_TESTSUITE', + clientVersion: '1.9', + }, + }, + }, + }) + + this.v1 = { WEB_REMIX, ANDROID_TESTSUITE } + + this.v3 = ky.create({ + prefixUrl: 'https://www.googleapis.com/youtube/v3/', + hooks: { beforeRequest: [authHook] }, + }) } private accessTokenRefreshRequest: Promise | null = null - public get accessToken() { + private get accessToken() { const refreshAccessToken = async () => { - const MAX_TRIES = 3 - let tries = 0 const refreshDetails = { client_id: PUBLIC_YOUTUBE_API_CLIENT_ID, client_secret: YOUTUBE_API_CLIENT_SECRET, @@ -507,23 +582,14 @@ class YTRequestManager { grant_type: 'refresh_token', } - while (tries < MAX_TRIES) { - ++tries - const response = await fetch('https://oauth2.googleapis.com/token', { - method: 'POST', - body: JSON.stringify(refreshDetails), - }).catch(() => null) - if (!response || !response.ok) continue + const { access_token, expires_in } = await ky.post('https://oauth2.googleapis.com/token', { json: refreshDetails, retry: 3 }).json<{ access_token: string; expires_in: number }>() - const { access_token, expires_in } = await response.json() - const expiry = Date.now() + expires_in * 1000 - return { accessToken: access_token as string, expiry } - } - - throw Error(`Failed to refresh access tokens for YouTube Music connection: ${this.connectionId}`) + const expiry = Date.now() + expires_in * 1000 + return { accessToken: access_token, expiry } } - if (this.expiry > Date.now()) return new Promise((resolve) => resolve(this.currentAccessToken)) + // ? Maybe build in a buffer to prevent a token expiring while a request is in flight + if (this.expiry >= Date.now()) return new Promise((resolve) => resolve(this.currentAccessToken)) if (this.accessTokenRefreshRequest) return this.accessTokenRefreshRequest @@ -542,54 +608,27 @@ class YTRequestManager { return this.accessTokenRefreshRequest } - - public async innerTubeFetch(relativeRefrence: string, options?: { body?: Record }) { - const url = new URL(relativeRefrence, 'https://music.youtube.com/youtubei/v1/') - - const headers = new Headers({ - 'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:88.0) Gecko/20100101 Firefox/88.0', - authorization: `Bearer ${await this.accessToken}`, - }) - - 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 context = { - client: { - clientName: 'WEB_REMIX', - clientVersion: `1.${year + month + day}.01.00`, - }, - } - - const body = Object.assign({ context }, options?.body) - - return fetch(url, { headers, method: 'POST', body: JSON.stringify(body) }) - } } -class YTLibaryManager { +class LibaryManager { private readonly connectionId: string - private readonly requestManager: YTRequestManager + private readonly api: APIManager private readonly youtubeUserId: string - constructor(connectionId: string, youtubeUserId: string, requestManager: YTRequestManager) { + constructor(connectionId: string, youtubeUserId: string, apiManager: APIManager) { this.connectionId = connectionId - this.requestManager = requestManager + this.api = apiManager this.youtubeUserId = youtubeUserId } public async albums(): Promise { - const albumData = await this.requestManager.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_liked_albums' } }).then((response) => response.json() as Promise) + const albumData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_albums' } }).json() const { items, continuations } = albumData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer let continuation = continuations?.[0].nextContinuationData.continuation while (continuation) { - const continuationData = await this.requestManager - .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) - .then((response) => response.json() as Promise) + const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json() items.push(...continuationData.continuationContents.gridContinuation.items) continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation @@ -614,17 +653,13 @@ class YTLibaryManager { } public async artists(): Promise { - const artistsData = await this.requestManager - .innerTubeFetch('/browse', { body: { browseId: 'FEmusic_library_corpus_track_artists' } }) - .then((response) => response.json() as Promise) + const artistsData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_library_corpus_track_artists' } }).json() const { contents, continuations } = artistsData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].musicShelfRenderer let continuation = continuations?.[0].nextContinuationData.continuation while (continuation) { - const continuationData = await this.requestManager - .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) - .then((response) => response.json() as Promise) + const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json() contents.push(...continuationData.continuationContents.musicShelfContinuation.contents) continuation = continuationData.continuationContents.musicShelfContinuation.continuations?.[0].nextContinuationData.continuation @@ -641,15 +676,13 @@ class YTLibaryManager { } public async playlists(): Promise { - const playlistData = await this.requestManager.innerTubeFetch('/browse', { body: { browseId: 'FEmusic_liked_playlists' } }).then((response) => response.json() as Promise) + const playlistData = await this.api.v1.WEB_REMIX('browse', { json: { browseId: 'FEmusic_liked_playlists' } }).json() const { items, continuations } = playlistData.contents.singleColumnBrowseResultsRenderer.tabs[0].tabRenderer.content.sectionListRenderer.contents[0].gridRenderer let continuation = continuations?.[0].nextContinuationData.continuation while (continuation) { - const continuationData = await this.requestManager - .innerTubeFetch(`/browse?ctoken=${continuation}&continuation=${continuation}`) - .then((response) => response.json() as Promise) + const continuationData = await this.api.v1.WEB_REMIX(`browse?ctoken=${continuation}&continuation=${continuation}`).json() items.push(...continuationData.continuationContents.gridContinuation.items) continuation = continuationData.continuationContents.gridContinuation.continuations?.[0].nextContinuationData.continuation diff --git a/src/lib/static/jellyfin-icon.svg b/src/lib/static/jellyfin-icon.svg deleted file mode 100644 index d4d7f01..0000000 --- a/src/lib/static/jellyfin-icon.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - icon-transparent - - - - - diff --git a/src/lib/static/youtube-music-icon.svg b/src/lib/static/youtube-music-icon.svg deleted file mode 100644 index 9dd9150..0000000 --- a/src/lib/static/youtube-music-icon.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - -]> - - - - - - - - - - - - - diff --git a/src/lib/stores.ts b/src/lib/stores.ts index ec008c1..a03be80 100644 --- a/src/lib/stores.ts +++ b/src/lib/stores.ts @@ -46,6 +46,12 @@ class Queue { return this.currentSongs[this.currentPosition] } + get upNext() { + if (this.currentSongs.length === 0 && this.currentPosition >= this.currentSongs.length) return null + + return this.currentSongs[this.currentPosition + 1] + } + get list() { return this.currentSongs } diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index e497dc0..be6067e 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -85,4 +85,7 @@ #sidebar { grid-area: 2 / 1 / 3 / 2; } + #content-wrapper { + grid-area: 2 / 2 / 3 / 4; + } diff --git a/src/routes/(app)/library/albums/albumCard.svelte b/src/routes/(app)/library/albums/albumCard.svelte index ad4b9f1..c477b9f 100644 --- a/src/routes/(app)/library/albums/albumCard.svelte +++ b/src/routes/(app)/library/albums/albumCard.svelte @@ -2,9 +2,9 @@ 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' + import ServiceLogo from '$lib/components/util/serviceLogo.svelte' export let album: Album @@ -33,7 +33,9 @@

- {Services[album.connection.type].displayName} +
+ +
{album.name}
diff --git a/src/routes/(app)/search/+page.server.ts b/src/routes/(app)/search/+page.server.ts index a11e457..943ffce 100644 --- a/src/routes/(app)/search/+page.server.ts +++ b/src/routes/(app)/search/+page.server.ts @@ -4,9 +4,9 @@ export const load: PageServerLoad = async ({ fetch, url, locals }) => { const query = url.searchParams.get('query') if (query) { const getSearchResults = async () => - fetch(`/api/search?query=${query}&userId=${locals.user.id}`, {}) - .then((response) => response.json() as Promise<{ searchResults: (Song | Album | Artist | Playlist)[] }>) - .then((data) => data.searchResults) + fetch(`/api/v1/search?query=${query}&userId=${locals.user.id}&types=song,album,artist,playlist`) + .then((response) => response.json() as Promise<{ results: (Song | Album | Artist | Playlist)[] }>) + .then((data) => data.results) return { searchResults: getSearchResults() } } diff --git a/src/routes/(app)/user/+page.server.ts b/src/routes/(app)/user/+page.server.ts index 5a595c2..af1de1e 100644 --- a/src/routes/(app)/user/+page.server.ts +++ b/src/routes/(app)/user/+page.server.ts @@ -3,29 +3,31 @@ import { YOUTUBE_API_CLIENT_SECRET } from '$env/static/private' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import type { PageServerLoad, Actions } from './$types' import { DB } from '$lib/server/db' -import { Jellyfin, JellyfinFetchError } from '$lib/server/jellyfin' +import { Jellyfin } from '$lib/server/jellyfin' import { google } from 'googleapis' +import ky from 'ky' -export const load: PageServerLoad = async ({ fetch, locals }) => { - const getConnectionInfo = async () => - fetch(`/api/users/${locals.user.id}/connections`) - .then((response) => response.json() as Promise<{ connections: ConnectionInfo[] }>) - .then((data) => data.connections) +export const load: PageServerLoad = async ({ fetch, locals, url }) => { + const getConnectionInfo = () => + ky + .get(`api/users/${locals.user.id}/connections`, { fetch, prefixUrl: url.origin }) + .json<{ connections: ConnectionInfo[] }>() + .then((response) => response.connections) .catch(() => ({ error: 'Failed to retrieve connections' })) return { connections: getConnectionInfo() } } export const actions: Actions = { - authenticateJellyfin: async ({ request, fetch, locals }) => { + authenticateJellyfin: async ({ request, fetch, locals, url }) => { const formData = await request.formData() const { serverUrl, username, password, deviceId } = Object.fromEntries(formData) if (!URL.canParse(serverUrl.toString())) return fail(400, { message: 'Invalid Server URL' }) - const authData = await Jellyfin.authenticateByName(username.toString(), password.toString(), new URL(serverUrl.toString()), deviceId.toString()).catch((error: JellyfinFetchError) => error) + const authData = await Jellyfin.authenticateByName(username.toString(), password.toString(), new URL(serverUrl.toString()), deviceId.toString()).catch(() => null) - if (authData instanceof JellyfinFetchError) return fail(authData.httpCode, { message: authData.message }) + if (!authData) return fail(400, { message: 'Failed to Authenticate' }) const userId = locals.user.id const serviceUserId = authData.User.Id @@ -33,13 +35,12 @@ export const actions: Actions = { 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[] }>) - .then((data) => data.connections[0]) + const connectionsResponse = await ky.get(`api/connections?id=${newConnectionId}`, { fetch, prefixUrl: url.origin }).json<{ connections: ConnectionInfo[] }>() + const newConnection = connectionsResponse.connections[0] return { newConnection } }, - youtubeMusicLogin: async ({ request, fetch, locals }) => { + youtubeMusicLogin: async ({ request, fetch, locals, url }) => { 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. @@ -56,9 +57,8 @@ export const actions: Actions = { .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[] }>) - .then((data) => data.connections[0]) + const connectionsResponse = await ky.get(`api/connections?id=${newConnectionId}`, { fetch, prefixUrl: url.origin }).json<{ connections: ConnectionInfo[] }>() + const newConnection = connectionsResponse.connections[0] return { newConnection } }, diff --git a/src/routes/(app)/user/+page.svelte b/src/routes/(app)/user/+page.svelte index b714505..0d0e462 100644 --- a/src/routes/(app)/user/+page.svelte +++ b/src/routes/(app)/user/+page.svelte @@ -3,8 +3,6 @@ import { goto } from '$app/navigation' import type { LayoutData } from '../$types' import Services from '$lib/services.json' - import JellyfinIcon from '$lib/static/jellyfin-icon.svg' - import YouTubeMusicIcon from '$lib/static/youtube-music-icon.svg' import JellyfinAuthBox from './jellyfinAuthBox.svelte' import { newestAlert } from '$lib/stores.js' import type { PageServerData } from './$types.js' @@ -15,6 +13,7 @@ import { enhance } from '$app/forms' import { PUBLIC_YOUTUBE_API_CLIENT_ID } from '$env/static/public' import Loader from '$lib/components/util/loader.svelte' + import ServiceLogo from '$lib/components/util/serviceLogo.svelte' export let data: PageServerData & LayoutData let connections: ConnectionInfo[] @@ -129,11 +128,15 @@

Add Connection

diff --git a/src/routes/api/connections/+server.ts b/src/routes/api/connections/+server.ts index 00625ba..3d4beb9 100644 --- a/src/routes/api/connections/+server.ts +++ b/src/routes/api/connections/+server.ts @@ -1,11 +1,11 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildConnection } from '$lib/server/api-helper' +import { ConnectionFactory } 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) => buildConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null) + const connections = (await Promise.all(ids.map((id) => ConnectionFactory.getConnection(id).catch(() => null)))).filter((result): result is Connection => result !== null) const getConnectionInfo = (connection: Connection) => connection.getConnectionInfo().catch((reason) => { diff --git a/src/routes/api/connections/[connectionId]/album/+server.ts b/src/routes/api/connections/[connectionId]/album/+server.ts index fe772b9..eb574bb 100644 --- a/src/routes/api/connections/[connectionId]/album/+server.ts +++ b/src/routes/api/connections/[connectionId]/album/+server.ts @@ -1,9 +1,9 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildConnection } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params, url }) => { const connectionId = params.connectionId! - const connection = await buildConnection(connectionId).catch(() => null) + const connection = await ConnectionFactory.getConnection(connectionId).catch(() => null) if (!connection) return new Response('Invalid connection id', { status: 400 }) const albumId = url.searchParams.get('id') diff --git a/src/routes/api/connections/[connectionId]/album/[albumId]/items/+server.ts b/src/routes/api/connections/[connectionId]/album/[albumId]/items/+server.ts index bff9444..d60f2a1 100644 --- a/src/routes/api/connections/[connectionId]/album/[albumId]/items/+server.ts +++ b/src/routes/api/connections/[connectionId]/album/[albumId]/items/+server.ts @@ -1,10 +1,10 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildConnection } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params }) => { const { connectionId, albumId } = params - const connection = await buildConnection(connectionId!).catch(() => null) + const connection = await ConnectionFactory.getConnection(connectionId!).catch(() => null) if (!connection) return new Response('Invalid connection id', { status: 400 }) const items = await connection.getAlbumItems(albumId!).catch(() => null) diff --git a/src/routes/api/connections/[connectionId]/playlist/+server.ts b/src/routes/api/connections/[connectionId]/playlist/+server.ts index 67340ff..b357e55 100644 --- a/src/routes/api/connections/[connectionId]/playlist/+server.ts +++ b/src/routes/api/connections/[connectionId]/playlist/+server.ts @@ -1,9 +1,9 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildConnection } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params, url }) => { const connectionId = params.connectionId! - const connection = await buildConnection(connectionId).catch(() => null) + const connection = await ConnectionFactory.getConnection(connectionId).catch(() => null) if (!connection) return new Response('Invalid connection id', { status: 400 }) const playlistId = url.searchParams.get('id') diff --git a/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts b/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts index 0dbcd94..e20c773 100644 --- a/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts +++ b/src/routes/api/connections/[connectionId]/playlist/[playlistId]/items/+server.ts @@ -1,9 +1,9 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildConnection } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params, url }) => { const { connectionId, playlistId } = params - const connection = await buildConnection(connectionId!).catch(() => null) + const connection = await ConnectionFactory.getConnection(connectionId!).catch(() => null) if (!connection) return new Response('Invalid connection id', { status: 400 }) const startIndexString = url.searchParams.get('startIndex') diff --git a/src/routes/api/search/+server.ts b/src/routes/api/search/+server.ts deleted file mode 100644 index 5a72fed..0000000 --- a/src/routes/api/search/+server.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { RequestHandler } from '@sveltejs/kit' -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 = 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 search = (connection: Connection) => - connection.search(query, checkedFilter).catch((reason) => { - console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`) - 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 }) -} diff --git a/src/routes/api/users/[userId]/connections/+server.ts b/src/routes/api/users/[userId]/connections/+server.ts index 69199e3..4d301b3 100644 --- a/src/routes/api/users/[userId]/connections/+server.ts +++ b/src/routes/api/users/[userId]/connections/+server.ts @@ -1,8 +1,8 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildUserConnections } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params }) => { - const userConnections = await buildUserConnections(params.userId!).catch(() => null) + const userConnections = await ConnectionFactory.getUserConnections(params.userId!).catch(() => null) if (!userConnections) return new Response('Invalid user id', { status: 400 }) const getConnectionInfo = (connection: Connection) => diff --git a/src/routes/api/users/[userId]/library/albums/+server.ts b/src/routes/api/users/[userId]/library/albums/+server.ts index bad45fb..25c6dc1 100644 --- a/src/routes/api/users/[userId]/library/albums/+server.ts +++ b/src/routes/api/users/[userId]/library/albums/+server.ts @@ -1,8 +1,8 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildUserConnections } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params }) => { - const userConnections = await buildUserConnections(params.userId!).catch(() => null) + const userConnections = await ConnectionFactory.getUserConnections(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() diff --git a/src/routes/api/users/[userId]/library/artists/+server.ts b/src/routes/api/users/[userId]/library/artists/+server.ts index 3a2257c..098f07f 100644 --- a/src/routes/api/users/[userId]/library/artists/+server.ts +++ b/src/routes/api/users/[userId]/library/artists/+server.ts @@ -1,8 +1,8 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildUserConnections } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params }) => { - const userConnections = await buildUserConnections(params.userId!).catch(() => null) + const userConnections = await ConnectionFactory.getUserConnections(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() diff --git a/src/routes/api/users/[userId]/library/playlists/+server.ts b/src/routes/api/users/[userId]/library/playlists/+server.ts index f4ee964..903f787 100644 --- a/src/routes/api/users/[userId]/library/playlists/+server.ts +++ b/src/routes/api/users/[userId]/library/playlists/+server.ts @@ -1,8 +1,8 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildUserConnections } from '$lib/server/api-helper' +import { ConnectionFactory } from '$lib/server/api-helper' export const GET: RequestHandler = async ({ params }) => { - const userConnections = await buildUserConnections(params.userId!).catch(() => null) + const userConnections = await ConnectionFactory.getUserConnections(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() diff --git a/src/routes/api/users/[userId]/recommendations/+server.ts b/src/routes/api/users/[userId]/recommendations/+server.ts index 9977276..591bdba 100644 --- a/src/routes/api/users/[userId]/recommendations/+server.ts +++ b/src/routes/api/users/[userId]/recommendations/+server.ts @@ -1,10 +1,10 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildUserConnections } from '$lib/server/api-helper' +import { ConnectionFactory } 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 userConnections = await buildUserConnections(params.userId!).catch(() => null) + const userConnections = await ConnectionFactory.getUserConnections(params.userId!).catch(() => null) if (!userConnections) return new Response('Invalid user id', { status: 400 }) const getRecommendations = (connection: Connection) => diff --git a/src/routes/api/audio/+server.ts b/src/routes/api/v1/audio/+server.ts similarity index 88% rename from src/routes/api/audio/+server.ts rename to src/routes/api/v1/audio/+server.ts index 56a7f43..0246d03 100644 --- a/src/routes/api/audio/+server.ts +++ b/src/routes/api/v1/audio/+server.ts @@ -1,12 +1,12 @@ import type { RequestHandler } from '@sveltejs/kit' -import { buildConnection } from '$lib/server/api-helper' +import { ConnectionFactory } 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 = await buildConnection(connectionId).catch(() => null) + const connection = await ConnectionFactory.getConnection(connectionId).catch(() => null) if (!connection) return new Response('Invalid connection id', { status: 400 }) const audioRequestHeaders = new Headers({ range: request.headers.get('range') ?? 'bytes=0-' }) diff --git a/src/routes/api/v1/search/+server.ts b/src/routes/api/v1/search/+server.ts new file mode 100644 index 0000000..3839615 --- /dev/null +++ b/src/routes/api/v1/search/+server.ts @@ -0,0 +1,35 @@ +import type { RequestHandler } from '@sveltejs/kit' +import { ConnectionFactory } from '$lib/server/api-helper' + +export const GET: RequestHandler = async ({ url }) => { + const query = url.searchParams.get('query') + const userId = url.searchParams.get('userId') + + const typeSet = new Set<'song' | 'album' | 'artist' | 'playlist'>() + + url.searchParams + .get('types') + ?.toLowerCase() + .split(',') + .forEach((type) => { + type = type.trim() + if (type === 'song' || type === 'album' || type === 'artist' || type === 'playlist') { + typeSet.add(type) + } + }) + + if (!(query && userId && typeSet.size > 0)) return new Response('Bad Request', { status: 400 }) + + const userConnections = await ConnectionFactory.getUserConnections(userId).catch(() => null) + if (!userConnections) return new Response('Bad Request', { status: 400 }) + + const search = (connection: Connection) => + connection.search(query, typeSet).catch((reason) => { + console.error(`Failed to search "${query}" from connection ${connection.id}: ${reason}`) + return null + }) + + const results = await Promise.all(userConnections.map(search)).then((results) => results.flat().filter((result) => result !== null)) + + return Response.json({ results }) +}