From a350a867523c7eac0638ffd371675d26ab998b51 Mon Sep 17 00:00:00 2001 From: Darren Labithiotis Date: Tue, 14 Jul 2026 19:52:52 +0100 Subject: [PATCH 1/7] add service to get po token --- .github/workflows/youtube-token.yml | 30 ++ bun.lock | 336 ++++++++++++++- package.json | 3 +- packages/cli/src/lib/network.test.ts | 58 +++ packages/cli/src/lib/network.ts | 80 +++- packages/cli/src/lib/sync.ts | 1 + packages/cli/src/lib/youtube.test.ts | 13 + packages/cli/src/lib/youtube.ts | 148 +++++-- .../.mdl.json" | 9 + packages/e2e/README.md | 4 + packages/e2e/package.json | 3 +- packages/e2e/youtube-token-smoke.test.ts | 71 +++ packages/youtube-token/README.md | 32 ++ packages/youtube-token/package.json | 21 + packages/youtube-token/src/index.ts | 403 ++++++++++++++++++ packages/youtube-token/tsconfig.json | 7 + .../youtube-token/worker-configuration.d.ts | 10 + packages/youtube-token/wrangler.jsonc | 31 ++ 18 files changed, 1199 insertions(+), 61 deletions(-) create mode 100644 .github/workflows/youtube-token.yml create mode 100644 packages/cli/src/lib/network.test.ts create mode 100644 "packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" create mode 100644 packages/e2e/youtube-token-smoke.test.ts create mode 100644 packages/youtube-token/README.md create mode 100644 packages/youtube-token/package.json create mode 100644 packages/youtube-token/src/index.ts create mode 100644 packages/youtube-token/tsconfig.json create mode 100644 packages/youtube-token/worker-configuration.d.ts create mode 100644 packages/youtube-token/wrangler.jsonc diff --git a/.github/workflows/youtube-token.yml b/.github/workflows/youtube-token.yml new file mode 100644 index 0000000..87da858 --- /dev/null +++ b/.github/workflows/youtube-token.yml @@ -0,0 +1,30 @@ +name: YouTube Token Worker + +on: + push: + branches: + - main + paths: + - ".github/workflows/youtube-token.yml" + - "packages/youtube-token/**" + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Check out + uses: actions/checkout@v6 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + + - name: Install dependencies + run: bun install --frozen-lockfile --filter @mdl/youtube-token + + - name: Deploy + working-directory: packages/youtube-token + run: bunx wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/bun.lock b/bun.lock index 38904c1..d630b8f 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ }, "packages/cli": { "name": "@mdlx/cli", - "version": "0.0.23", + "version": "0.0.24", "bin": { "mdl": "release/cli.js", }, @@ -58,6 +58,18 @@ "wrangler": "4.14.4", }, }, + "packages/youtube-token": { + "name": "@mdl/youtube-token-worker", + "version": "0.0.0", + "dependencies": { + "@cloudflare/puppeteer": "1.1.0", + "bgutils-js": "3.2.0", + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20260710.1", + "wrangler": "4.110.0", + }, + }, }, "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.3.0", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-p+CMKJ93HFmLkjXKlXiVGlMQEuRb6H0MokBSwUsX+S6BRX8eV5naFZpQJFfJHjRZY0Hmnqy1/r6UWl3x+19zYA=="], @@ -86,6 +98,8 @@ "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.0", "", { "dependencies": { "mime": "^3.0.0" } }, "sha512-+tv3z+SPp+gqTIcImN9o0hqE9xyfQjI1XD9pL6NuKjua9B1y7mNYv0S9cP+QEbA4ppVgGZEmKOvHX5G5Ei1CVA=="], + "@cloudflare/puppeteer": ["@cloudflare/puppeteer@1.1.0", "", { "dependencies": { "@puppeteer/browsers": "2.2.4", "debug": "^4.3.5", "devtools-protocol": "0.0.1299070", "ws": "^8.18.0" } }, "sha512-lN10En49avRDQvz8Gpv/WiIoGvjjDiP6P+v2y9bA6rfPT5WHfnlg2O6MwjHc1POm8A9LXf1sMdgrsdW8xWapOw=="], + "@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.3.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.15", "workerd": "^1.20250320.0" }, "optionalPeers": ["workerd"] }, "sha512-Xq57Qd+ADpt6hibcVBO0uLG9zzRgyRhfCUgBT9s+g3+3Ivg5zDyVgLFy40ES1VdNcu8rPNSivm9A+kGP5IVaPg=="], "@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20250507.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-xC+8hmQuOUUNCVT9DWpLMfxhR4Xs4kI8v7Bkybh4pzGC85moH6fMfCBNaP0YQCNAA/BR56aL/AwfvMVGskTK/A=="], @@ -150,7 +164,7 @@ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg=="], "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], @@ -162,6 +176,8 @@ "@fastify/busboy": ["@fastify/busboy@2.1.1", "", {}, "sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="], "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="], @@ -174,6 +190,10 @@ "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="], + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="], "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="], @@ -186,6 +206,10 @@ "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="], + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="], "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="], @@ -196,6 +220,8 @@ "@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="], + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="], "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="], @@ -210,12 +236,22 @@ "@mdl/web": ["@mdl/web@workspace:packages/web"], + "@mdl/youtube-token-worker": ["@mdl/youtube-token-worker@workspace:packages/youtube-token"], + "@mdlx/cli": ["@mdlx/cli@workspace:packages/cli"], "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="], "@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="], + "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], + + "@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="], + + "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + + "@puppeteer/browsers": ["@puppeteer/browsers@2.2.4", "", { "dependencies": { "debug": "^4.3.5", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.4.0", "semver": "^7.6.2", "tar-fs": "^3.0.6", "unbzip2-stream": "^1.4.3", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-BdG2qiI1dn89OTUUsx2GZSpUzW+DRffR1wlMJyKxVHYrhnKoELSDxDd+2XImUkuWPEKk76H5FcM/gPFrEK1Tfw=="], + "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.11", "", { "os": "android", "cpu": "arm64" }, "sha512-SJ+/g+xNnOh6NqYxD0V3uVN4W3VfnrGsC9/hoglicgTNfABFG9JjISvkkU0dNY84MNHLWyOgxP9v9Y9pX4S7+A=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.0-rc.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-7WQgR8SfOPwmDZGFkThUvsmd/nwAWv91oCO4I5LS7RKrssPZmOt7jONN0cW17ydGC1n/+puol1IpoieKqQidmg=="], @@ -298,6 +334,10 @@ "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], + "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], + + "@speed-highlight/core": ["@speed-highlight/core@1.2.17", "", {}, "sha512-Z92FwKpCtfaW1V0jTU/fh3QzYEZN8wDwrzRIBoADCJfn4mJCNcJN/XegifX7BDrQ8/h9Xh/JnbyMchL0FqXrkg=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tailwindcss/node": ["@tailwindcss/node@4.1.3", "", { "dependencies": { "enhanced-resolve": "^5.18.1", "jiti": "^2.4.2", "lightningcss": "1.29.2", "tailwindcss": "4.1.3" } }, "sha512-H/6r6IPFJkCfBJZ2dKZiPJ7Ueb2wbL592+9bQEl2r73qbX6yGnmQVIfiUvDRB2YI0a3PWDrzUwkvQx1XW1bNkA=="], @@ -328,6 +368,8 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.1.3", "", { "dependencies": { "@tailwindcss/node": "4.1.3", "@tailwindcss/oxide": "4.1.3", "tailwindcss": "4.1.3" }, "peerDependencies": { "vite": "^5.2.0 || ^6" } }, "sha512-lUI/QaDxLtlV52Lho6pu07CG9pSnRYLOPmKGIQjyHdTBagemc6HmgZxyjGAQ/5HMPrNeWBfTVIpQl0/jLXvWHQ=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], @@ -340,12 +382,16 @@ "@types/react-dom": ["@types/react-dom@19.2.2", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], "acorn-walk": ["acorn-walk@8.3.2", "", {}, "sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "1.1.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -354,10 +400,36 @@ "as-table": ["as-table@1.0.55", "", { "dependencies": { "printable-characters": "^1.0.42" } }, "sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ=="], + "ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="], + "auto-bind": ["auto-bind@5.0.1", "", {}, "sha512-ooviqdwwgfIfNmDwo94wlshcdzfO64XV0Cg6oDsDYBJfITDz1EngD2z7DkbvCWn+XIMsIqW27sEVF6qcpJrRcg=="], + "b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="], + + "bare-events": ["bare-events@2.9.1", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg=="], + + "bare-fs": ["bare-fs@4.7.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-aTvMFUWkBmjzKtEQMDGGDNF8bkfpD5N1b/FCwt7A3wrU4t1o/e/85Wzkluh6JlODCjqVESYCkQCdTXqZ9G7VFg=="], + + "bare-os": ["bare-os@3.9.1", "", {}, "sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ=="], + + "bare-path": ["bare-path@3.0.1", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-ghj2DSK/2e99a1anTVPCV4m4YIYtrbXhfM7V3D7XZLOTsybnYyaJloymGqssQc8l/or0UoDyRtNQkmkEF/ysgQ=="], + + "bare-stream": ["bare-stream@2.13.1", "", { "dependencies": { "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow=="], + + "bare-url": ["bare-url@2.4.5", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "basic-ftp": ["basic-ftp@5.3.1", "", {}, "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw=="], + + "bgutils-js": ["bgutils-js@3.2.0", "", {}, "sha512-CacO15JvxbclbLeCAAm9DETGlLuisRGWpPigoRvNsccSCPEC4pwYwA2g2x/pv7Om/sk79d4ib35V5HHmxPBpDg=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], @@ -370,6 +442,8 @@ "cli-truncate": ["cli-truncate@6.0.0", "", { "dependencies": { "slice-ansi": "^9.0.0", "string-width": "^8.2.0" } }, "sha512-3+YKIUFsohD9MIoOFPFBldjAlnfCmCDcqe6aYGFqlDTRKg80p4wg35L+j83QQ63iOlKRccEkbn8IuM++HsgEjA=="], + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + "code-excerpt": ["code-excerpt@4.0.0", "", { "dependencies": { "convert-to-spaces": "2.0.1" } }, "sha512-xxodCmBen3iy2i0WtAK8FlFNrRzjUqjRsMfho58xT/wvZU1YTM3fCnRjcy1gJPMepaRlgm/0e6w8SpWHpn3/cA=="], "color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="], @@ -390,44 +464,84 @@ "data-uri-to-buffer": ["data-uri-to-buffer@2.0.2", "", {}, "sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "devtools-protocol": ["devtools-protocol@0.0.1299070", "", {}, "sha512-+qtL3eX50qsJ7c+qVyagqi7AWMoQCBGNfoyJZMwm/NSXVqLYbuitrWEEIzxfUmTNy7//Xe8yhMmQ+elj3uAqSg=="], + "effect": ["effect@3.21.0", "", { "dependencies": { "@standard-schema/spec": "1.1.0", "fast-check": "3.23.2" } }, "sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-toolkit": ["es-toolkit@1.45.1", "", {}, "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw=="], "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-east-asian-width": ["get-east-asian-width@1.5.0", "", {}, "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA=="], "get-source": ["get-source@2.0.12", "", { "dependencies": { "data-uri-to-buffer": "^2.0.0", "source-map": "^0.6.1" } }, "sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + + "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], + "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], "has-flag": ["has-flag@5.0.1", "", {}, "sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + "indent-string": ["indent-string@5.0.0", "", {}, "sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg=="], "ink": ["ink@7.0.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.3.0", "ansi-escapes": "^7.3.0", "ansi-styles": "^6.2.3", "auto-bind": "^5.0.1", "chalk": "^5.6.2", "cli-boxes": "^4.0.1", "cli-cursor": "^4.0.0", "cli-truncate": "^6.0.0", "code-excerpt": "^4.0.0", "es-toolkit": "^1.45.1", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "scheduler": "^0.27.0", "signal-exit": "^3.0.7", "slice-ansi": "^9.0.0", "stack-utils": "^2.0.6", "string-width": "^8.2.0", "terminal-size": "^4.0.1", "type-fest": "^5.5.0", "widest-line": "^6.0.0", "wrap-ansi": "^10.0.0", "ws": "^8.20.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.2.0", "react": ">=19.2.0", "react-devtools-core": ">=6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-fMie5/VwIYXofMyND0s+fOVhwVBBPYx+uuqJ6V6rUBGjui+2UYp+0fWtvhSeKT4z+X1uH98a4ge5Vj3aTlL6mg=="], @@ -440,6 +554,8 @@ "ink-testing-library": ["ink-testing-library@4.0.0", "", { "optionalDependencies": { "@types/react": "19.2.2" } }, "sha512-yF92kj3pmBvk7oKbSq5vEALO//o7Z9Ck/OaLNlkzXNeYdwfpxMQkSowGTFUCS5MSu9bWfSZMewGpp7bFc66D7Q=="], + "ip-address": ["ip-address@10.2.0", "", {}, "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA=="], + "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "1.5.0" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], @@ -448,6 +564,8 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], + "lefthook": ["lefthook@2.1.5", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.5", "lefthook-darwin-x64": "2.1.5", "lefthook-freebsd-arm64": "2.1.5", "lefthook-freebsd-x64": "2.1.5", "lefthook-linux-arm64": "2.1.5", "lefthook-linux-x64": "2.1.5", "lefthook-openbsd-arm64": "2.1.5", "lefthook-openbsd-x64": "2.1.5", "lefthook-windows-arm64": "2.1.5", "lefthook-windows-x64": "2.1.5" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A=="], "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VITTaw8PxxyE26gkZ8UcwIa5ZrWnKNRGLeeSrqri40cQdXvLTEoMq2tjjw7eiL9UcB0waRReDdzydevy9GOPUQ=="], @@ -494,6 +612,8 @@ "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + "lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], + "meriyah": ["meriyah@6.1.4", "", {}, "sha512-Sz8FzjzI0kN13GK/6MVEsVzMZEPvOhnmmI1lU5+/1cGOiK3QUahntrNNtdVeihrO7t9JpoH75iMNXg6R6uWflQ=="], "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], @@ -502,20 +622,32 @@ "miniflare": ["miniflare@4.20250507.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "acorn": "8.14.0", "acorn-walk": "8.3.2", "exit-hook": "2.2.1", "glob-to-regexp": "0.4.1", "stoppable": "1.1.0", "undici": "^5.28.5", "workerd": "1.20250507.0", "ws": "8.18.0", "youch": "3.3.4", "zod": "3.22.3" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-EgbQRt/Hnr8HCmW2J/4LRNE3yOzJTdNd98XJ8gnGXFKcimXxUFPiWP3k1df+ZPCtEHp6cXxi8+jP7v9vuIbIsg=="], + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "mustache": ["mustache@4.2.0", "", { "bin": { "mustache": "bin/mustache" } }, "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + "netmask": ["netmask@2.1.1", "", {}, "sha512-eonl3sLUha+S1GzTPxychyhnUzKyeQkZ7jLjKrBagJgPla13F+uQ71HgpFefyHgqrjEbCPkDArxYsjY8/+gLKA=="], + "ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], + + "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], + "patch-console": ["patch-console@2.0.0", "", {}, "sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA=="], "path-to-regexp": ["path-to-regexp@6.3.0", "", {}, "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + "pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], @@ -524,6 +656,14 @@ "printable-characters": ["printable-characters@1.0.42", "", {}, "sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ=="], + "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], @@ -534,6 +674,8 @@ "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "0.27.0" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + "restore-cursor": ["restore-cursor@4.0.0", "", { "dependencies": { "onetime": "5.1.2", "signal-exit": "3.0.7" } }, "sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg=="], "rolldown": ["rolldown@1.0.0-rc.11", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.11" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-arm64": "1.0.0-rc.11", "@rolldown/binding-darwin-x64": "1.0.0-rc.11", "@rolldown/binding-freebsd-x64": "1.0.0-rc.11", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.11", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.11", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.11", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.11", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.11", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.11", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.11" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-NRjoKMusSjfRbSYiH3VSumlkgFe7kYAa3pzVOsVYVFY3zb5d7nS+a3KGQ7hJKXuYWbzJKPVQ9Wxq2UvyK+ENpw=="], @@ -556,6 +698,12 @@ "slice-ansi": ["slice-ansi@9.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "is-fullwidth-code-point": "^5.1.0" } }, "sha512-SO/3iYL5S3W57LLEniscOGPZgOqZUPCx6d3dB+52B80yJ0XstzsC/eV8gnA4tM3MHDrKz+OCFSLNjswdSC+/bA=="], + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.9", "", { "dependencies": { "ip-address": "^10.1.1", "smart-buffer": "^4.2.0" } }, "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw=="], + + "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], + "source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -566,6 +714,8 @@ "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], + "streamx": ["streamx@2.27.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-WZ189TKnHoAokYHvwzaAQMpd55cgUmFIcJFzBSgGcb886jau5DL+XdDhTWV4ps3FLvk+OORp0dLRTPsLZ21CSA=="], + "string-width": ["string-width@8.2.0", "", { "dependencies": { "get-east-asian-width": "1.5.0", "strip-ansi": "7.2.0" } }, "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw=="], "strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -580,10 +730,20 @@ "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], + + "tar-stream": ["tar-stream@3.2.0", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg=="], + + "teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="], + "terminal-link": ["terminal-link@5.0.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "supports-hyperlinks": "^4.1.0" } }, "sha512-qFAy10MTMwjzjU8U16YS4YoZD+NQLHzLssFMNqgravjbvIPNiqkGFR4yjhJfmY9R5OFU7+yHxc6y+uGHkKwLRA=="], "terminal-size": ["terminal-size@4.0.1", "", {}, "sha512-avMLDQpUI9I5XFrklECw1ZEUPJhqzcwSWsyyI8blhRLT+8N1jLJWLWWYQpB2q2xthq8xDvjZPISVh53T/+CLYQ=="], + "text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="], + + "through": ["through@2.3.8", "", {}, "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="], + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], "truncate-utf8-bytes": ["truncate-utf8-bytes@1.0.2", "", { "dependencies": { "utf8-byte-length": "1.0.5" } }, "sha512-95Pu1QXQvruGEhv62XCMO3Mm90GscOCClvrIUwCM0PYOXK3kaF3l3sIHxx71ThJfcbM2O5Au6SO3AWCSEfW4mQ=="], @@ -596,6 +756,8 @@ "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "unbzip2-stream": ["unbzip2-stream@1.4.3", "", { "dependencies": { "buffer": "^5.2.1", "through": "^2.3.8" } }, "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg=="], + "undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -614,23 +776,45 @@ "wrap-ansi": ["wrap-ansi@10.0.0", "", { "dependencies": { "ansi-styles": "^6.2.3", "string-width": "^8.2.0", "strip-ansi": "^7.1.2" } }, "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ=="], - "ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="], "yoga-layout": ["yoga-layout@3.2.1", "", {}, "sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ=="], "youch": ["youch@3.3.4", "", { "dependencies": { "cookie": "^0.7.1", "mustache": "^4.2.0", "stacktracey": "^2.1.8" } }, "sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg=="], + "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "youtubei.js": ["youtubei.js@17.0.1", "", { "dependencies": { "@bufbuild/protobuf": "2.11.0", "meriyah": "6.1.4" } }, "sha512-1lO4b8UqMDzE0oh2qEGzbBOd4UYRdxn/4PdpRM7BGTHxM6ddsEsKZTu90jp8V9FHVgC2h1UirQyqoqLiKwl+Zg=="], "zod": ["zod@3.22.3", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="], + "@mdl/youtube-token-worker/@cloudflare/workers-types": ["@cloudflare/workers-types@5.20260710.1", "", {}, "sha512-4ooaY2Pb5XGwDn8Fzm6jnTAJkIX0R5LBvL9euQpp2T58sQItlAQd9yivAlkwGhpY5cM1u81/9HaXwKAjXwtyzA=="], + + "@mdl/youtube-token-worker/wrangler": ["wrangler@4.110.0", "", { "dependencies": { "@cloudflare/kv-asset-handler": "0.5.0", "@cloudflare/unenv-preset": "2.16.1", "blake3-wasm": "2.1.5", "esbuild": "0.28.1", "miniflare": "4.20260708.1", "path-to-regexp": "6.3.0", "unenv": "2.0.0-rc.24", "workerd": "1.20260708.1" }, "optionalDependencies": { "fsevents": "2.3.3" }, "peerDependencies": { "@cloudflare/workers-types": "^5.20260708.1" }, "optionalPeers": ["@cloudflare/workers-types"], "bin": { "cf-wrangler": "bin/cf-wrangler.js", "wrangler": "bin/wrangler.js", "wrangler2": "bin/wrangler.js" } }, "sha512-xZeXKYi7hxQRF5anL+v77RkufJNpF9f3Eqeyqq2QBsETpLZgh0Agj0jJ6JPtkbgn6ukZdh8OK5egsGPWIditgg=="], + "@tailwindcss/node/lightningcss": ["lightningcss@1.29.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-darwin-arm64": "1.29.2", "lightningcss-darwin-x64": "1.29.2", "lightningcss-freebsd-x64": "1.29.2", "lightningcss-linux-arm-gnueabihf": "1.29.2", "lightningcss-linux-arm64-gnu": "1.29.2", "lightningcss-linux-arm64-musl": "1.29.2", "lightningcss-linux-x64-gnu": "1.29.2", "lightningcss-linux-x64-musl": "1.29.2", "lightningcss-win32-arm64-msvc": "1.29.2", "lightningcss-win32-x64-msvc": "1.29.2" } }, "sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA=="], "@tailwindcss/vite/vite": ["vite@6.3.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.3", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.12" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-kkzzkqtMESYklo96HKKPE5KKLkC1amlsqt+RjFMlX2AvbRB/0wghap19NdBxxwGZ+h/C6DLCrcEphPIItlGrRQ=="], "bun-types/@types/node": ["@types/node@24.6.0", "", { "dependencies": { "undici-types": "7.13.0" } }, "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA=="], - "ink/ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], "ink-scroll-view/ink": ["ink@6.8.0", "", { "dependencies": { "@alcalzone/ansi-tokenize": "0.2.5", "ansi-escapes": "7.3.0", "ansi-styles": "6.2.3", "auto-bind": "5.0.1", "chalk": "5.6.2", "cli-boxes": "3.0.0", "cli-cursor": "4.0.0", "cli-truncate": "5.2.0", "code-excerpt": "4.0.0", "es-toolkit": "1.45.1", "indent-string": "5.0.0", "is-in-ci": "2.0.0", "patch-console": "2.0.0", "react-reconciler": "0.33.0", "scheduler": "0.27.0", "signal-exit": "3.0.7", "slice-ansi": "8.0.0", "stack-utils": "2.0.6", "string-width": "8.2.0", "terminal-size": "4.0.1", "type-fest": "5.5.0", "widest-line": "6.0.0", "wrap-ansi": "9.0.2", "ws": "8.19.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@types/react": "19.2.2" }, "peerDependencies": { "react": "19.2.0" } }, "sha512-sbl1RdLOgkO9isK42WCZlJCFN9hb++sX9dsklOvfd1YQ3bQ2AiFu12Q6tFlr0HvEUvzraJntQCCpfEoUe9DSzA=="], @@ -638,8 +822,24 @@ "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.11", "", {}, "sha512-xQO9vbwBecJRv9EUcQ/y0dzSTJgA7Q6UVN7xp6B81+tBGSLVAK03yJ9NkJaUA7JFD91kbjxRSC/mDnmvXzbHoQ=="], + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@mdl/youtube-token-worker/wrangler/@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.5.0", "", {}, "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg=="], + + "@mdl/youtube-token-worker/wrangler/@cloudflare/unenv-preset": ["@cloudflare/unenv-preset@2.16.1", "", { "peerDependencies": { "unenv": "2.0.0-rc.24", "workerd": ">1.20260305.0 <2.0.0-0" }, "optionalPeers": ["workerd"] }, "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw=="], + + "@mdl/youtube-token-worker/wrangler/esbuild": ["esbuild@0.28.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.1", "@esbuild/android-arm": "0.28.1", "@esbuild/android-arm64": "0.28.1", "@esbuild/android-x64": "0.28.1", "@esbuild/darwin-arm64": "0.28.1", "@esbuild/darwin-x64": "0.28.1", "@esbuild/freebsd-arm64": "0.28.1", "@esbuild/freebsd-x64": "0.28.1", "@esbuild/linux-arm": "0.28.1", "@esbuild/linux-arm64": "0.28.1", "@esbuild/linux-ia32": "0.28.1", "@esbuild/linux-loong64": "0.28.1", "@esbuild/linux-mips64el": "0.28.1", "@esbuild/linux-ppc64": "0.28.1", "@esbuild/linux-riscv64": "0.28.1", "@esbuild/linux-s390x": "0.28.1", "@esbuild/linux-x64": "0.28.1", "@esbuild/netbsd-arm64": "0.28.1", "@esbuild/netbsd-x64": "0.28.1", "@esbuild/openbsd-arm64": "0.28.1", "@esbuild/openbsd-x64": "0.28.1", "@esbuild/openharmony-arm64": "0.28.1", "@esbuild/sunos-x64": "0.28.1", "@esbuild/win32-arm64": "0.28.1", "@esbuild/win32-ia32": "0.28.1", "@esbuild/win32-x64": "0.28.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare": ["miniflare@4.20260708.1", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "0.34.5", "undici": "7.28.0", "workerd": "1.20260708.1", "ws": "8.21.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-c94O9zRDISdqO18EHt6l0iF/fWgWt8p18PJvRsA/L/NJZ9Cfke3s/F5Blg1XXF7WDutVRzWVWy8Vy4LaT5ifsA=="], + + "@mdl/youtube-token-worker/wrangler/unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + + "@mdl/youtube-token-worker/wrangler/workerd": ["workerd@1.20260708.1", "", { "optionalDependencies": { "@cloudflare/workerd-darwin-64": "1.20260708.1", "@cloudflare/workerd-darwin-arm64": "1.20260708.1", "@cloudflare/workerd-linux-64": "1.20260708.1", "@cloudflare/workerd-linux-arm64": "1.20260708.1", "@cloudflare/workerd-windows-64": "1.20260708.1" }, "bin": { "workerd": "bin/workerd" } }, "sha512-WAK+Kt/VVCSldH2qSr8lx46XCJ4Q+bdlHNaFqUtOHthBEIB8C1N8HVW+VOLrxDoTCk0NGNv0zajnBeQK4JOB9w=="], + "@tailwindcss/node/lightningcss/lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.29.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA=="], "@tailwindcss/node/lightningcss/lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.29.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w=="], @@ -664,6 +864,12 @@ "bun-types/@types/node/undici-types": ["undici-types@7.13.0", "", {}, "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ=="], + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "cliui/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "ink-scroll-view/ink/@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.5", "", { "dependencies": { "ansi-styles": "6.2.3", "is-fullwidth-code-point": "5.1.0" } }, "sha512-3NX/MpTdroi0aKz134A6RC2Gb2iXVECN4QaAXnvCIxxIm3C3AVB1mkUe8NaaiyvOpDfsrqWhYtj+Q6a62RrTsw=="], "ink-scroll-view/ink/cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -688,6 +894,78 @@ "ink-spinner/ink/ws": ["ws@8.19.0", "", {}, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.1", "", { "os": "android", "cpu": "arm" }, "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.1", "", { "os": "android", "cpu": "arm64" }, "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.1", "", { "os": "android", "cpu": "x64" }, "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.1", "", { "os": "linux", "cpu": "arm" }, "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.1", "", { "os": "linux", "cpu": "none" }, "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.1", "", { "os": "linux", "cpu": "x64" }, "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.1", "", { "os": "none", "cpu": "arm64" }, "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.1", "", { "os": "none", "cpu": "x64" }, "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg=="], + + "@mdl/youtube-token-worker/wrangler/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/ws": ["ws@8.21.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/youch": ["youch@4.1.0-beta.10", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@poppinss/dumper": "^0.6.4", "@speed-highlight/core": "^1.2.7", "cookie": "^1.0.2", "youch-core": "^0.3.3" } }, "sha512-rLfVLB4FgQneDr0dv1oddCVZmKjcJ6yX6mS4pU82Mq/Dt9a3cLZQ62pDBL4AUO+uVrCvtWz3ZFUL2HFAFJ/BXQ=="], + + "@mdl/youtube-token-worker/wrangler/workerd/@cloudflare/workerd-darwin-64": ["@cloudflare/workerd-darwin-64@1.20260708.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-HXFCvhS1wpg3uXO0CLUwmwC41i2loM5FSK69EUchOBpmYBAXxT1oHLm6EOA5lqhTk5Mu9kjRiQYxa1GwKPwfJg=="], + + "@mdl/youtube-token-worker/wrangler/workerd/@cloudflare/workerd-darwin-arm64": ["@cloudflare/workerd-darwin-arm64@1.20260708.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-JVlJaKDoRTVKSroHIlf8g3UCPjKj4iDbMZE2CNYht5qQ+2rL0FAUiVlV82G3BqKnnw9kHYnnsMzC08b9zVtdzA=="], + + "@mdl/youtube-token-worker/wrangler/workerd/@cloudflare/workerd-linux-64": ["@cloudflare/workerd-linux-64@1.20260708.1", "", { "os": "linux", "cpu": "x64" }, "sha512-3daE60YdD7YX0Jtuzc9DE/r/qMkmx8ZvHTkF8Mzmp3F5tbzlV0DAzmu5PFUPF2WuvtKbAhZKbvC2cHmWpQYxnA=="], + + "@mdl/youtube-token-worker/wrangler/workerd/@cloudflare/workerd-linux-arm64": ["@cloudflare/workerd-linux-arm64@1.20260708.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-VLdNYOx5Hj+9C6isy0ACWZsbMtSxex2DIJWEe7cZxUdlphZ58ZT8zxNXK8yunFiowd34hn3VwGMopdvdj8lvmA=="], + + "@mdl/youtube-token-worker/wrangler/workerd/@cloudflare/workerd-windows-64": ["@cloudflare/workerd-windows-64@1.20260708.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bC/aSAwLy16Vjo24i9XU3aWH+eRgz7NeR5xPKavGbembO18ZywYTQbXh14eXtY6fAqN3RzRG8psijTdhX4xydA=="], + "@tailwindcss/vite/vite/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], "@tailwindcss/vite/vite/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], @@ -730,6 +1008,8 @@ "@tailwindcss/vite/vite/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + "@tailwindcss/vite/vite/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + "@tailwindcss/vite/vite/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], "@tailwindcss/vite/vite/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], @@ -741,5 +1021,51 @@ "ink-scroll-view/ink/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "10.6.0", "get-east-asian-width": "1.5.0", "strip-ansi": "7.2.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "ink-spinner/ink/wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "10.6.0", "get-east-asian-width": "1.5.0", "strip-ansi": "7.2.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "yargs/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/sharp/@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + + "@mdl/youtube-token-worker/wrangler/miniflare/youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + + "ink-scroll-view/ink/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "ink-spinner/ink/wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], } } diff --git a/package.json b/package.json index 9ff6c1a..cea3bc0 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "typecheck": "tsc --noEmit -p packages/cli/tsconfig.json", "test": "bun run test && bun run test:e2e", "test:unit": "bun run --filter '@mdlx/cli' test:unit", - "test:e2e": "bun run --filter '@mdl/e2e' test:e2e" + "test:e2e": "bun run --filter '@mdl/e2e' test:e2e", + "test:youtube-smoke": "bun run --filter '@mdl/e2e' test:youtube-smoke" }, "devDependencies": { "@biomejs/biome": "2.4.8", diff --git a/packages/cli/src/lib/network.test.ts b/packages/cli/src/lib/network.test.ts new file mode 100644 index 0000000..aeb0203 --- /dev/null +++ b/packages/cli/src/lib/network.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, mock, test } from 'bun:test'; +import { + configureNetwork, + getYouTubePoToken, + getYouTubeSessionOptions, +} from './network'; + +const defaultFetch = globalThis.fetch; + +afterEach(() => { + configureNetwork({}); + globalThis.fetch = defaultFetch; + mock.restore(); +}); + +describe('network', () => { + test('fetches YouTube PO tokens from the default worker', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + expect(String(input)).toBe( + 'https://mdl-youtube-token.canari.workers.dev/token?videoId=abc123' + ); + + return new Response( + JSON.stringify({ + poToken: 'po-token-123', + visitorData: 'visitor-data-123', + }) + ); + }); + + configureNetwork({}); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(getYouTubePoToken('abc123')).resolves.toEqual({ + poToken: 'po-token-123', + visitorData: 'visitor-data-123', + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect( + getYouTubeSessionOptions({ + poToken: 'po-token-123', + visitorData: 'visitor-data-123', + }) + ).toMatchObject({ + visitor_data: 'visitor-data-123', + }); + }); + + test('rejects token responses without visitor data', async () => { + globalThis.fetch = mock( + async () => new Response(JSON.stringify({ poToken: 'po-token-123' })) + ) as unknown as typeof fetch; + + await expect(getYouTubePoToken('abc123')).rejects.toThrow( + 'YouTube token provider did not return visitor data.' + ); + }); +}); diff --git a/packages/cli/src/lib/network.ts b/packages/cli/src/lib/network.ts index 78daed7..bfa9ffe 100644 --- a/packages/cli/src/lib/network.ts +++ b/packages/cli/src/lib/network.ts @@ -1,4 +1,5 @@ import { ProxyAgent, fetch as undiciFetch } from 'undici'; +import type { SessionOptions } from 'youtubei.js'; export type NetworkOptions = { proxy?: string; @@ -6,7 +7,16 @@ export type NetworkOptions = { ytUserAgent?: string; }; +export type YouTubePoToken = { + expiresAt?: string; + poToken: string; + visitorData?: string; +}; + const defaultFetch = globalThis.fetch.bind(globalThis); +const YOUTUBE_PO_TOKEN_PROVIDER_URL = + process.env.MDL_YOUTUBE_TOKEN_PROVIDER_URL ?? + 'https://mdl-youtube-token.canari.workers.dev/token'; let currentNetworkOptions: NetworkOptions = {}; let proxyAgent: ProxyAgent | null = null; @@ -25,23 +35,47 @@ export function configureNetwork(options: NetworkOptions): void { } proxyAgent = new ProxyAgent(options.proxy); - console.log('=======> Using proxy', options.proxy); globalThis.fetch = createProxyFetch(proxyAgent); } -export function getYouTubeSessionOptions(): { - cookie?: string; - fetch: typeof fetch; - po_token?: string; - user_agent?: string; -} { +export function getYouTubeSessionOptions( + poToken?: YouTubePoToken +): Partial { return { cookie: currentNetworkOptions.ytCookie, - fetch: globalThis.fetch.bind(globalThis), user_agent: currentNetworkOptions.ytUserAgent, + visitor_data: poToken?.visitorData, + po_token: poToken?.poToken, + fetch: globalThis.fetch.bind(globalThis), }; } +export function getYouTubeSessionCacheKey(poToken?: YouTubePoToken): string { + return JSON.stringify({ + cookie: currentNetworkOptions.ytCookie, + userAgent: currentNetworkOptions.ytUserAgent, + poToken: poToken?.poToken, + visitorData: poToken?.visitorData, + }); +} + +export async function getYouTubePoToken( + videoId: string, + signal?: AbortSignal +): Promise { + const url = new URL(YOUTUBE_PO_TOKEN_PROVIDER_URL); + url.searchParams.set('videoId', videoId); + const response = await fetch(url, { signal }); + + if (!response.ok) { + throw new Error( + `YouTube token provider failed (${response.status}): ${await response.text()}` + ); + } + + return parseYouTubePoToken(await response.json()); +} + function createProxyFetch(agent: ProxyAgent): typeof fetch { return (async ( input: Parameters[0], @@ -55,3 +89,33 @@ function createProxyFetch(agent: ProxyAgent): typeof fetch { } as never )) as unknown as typeof fetch; } + +function parseYouTubePoToken(value: unknown): YouTubePoToken { + if (!value || typeof value !== 'object') { + throw new Error('YouTube token provider returned an invalid payload.'); + } + + const payload = value as Partial; + + if ( + typeof payload.poToken !== 'string' || + payload.poToken.trim().length === 0 + ) { + throw new Error('YouTube token provider did not return a PO token.'); + } + + if ( + typeof payload.visitorData !== 'string' || + payload.visitorData.trim().length === 0 + ) { + throw new Error('YouTube token provider did not return visitor data.'); + } + + return { + poToken: payload.poToken, + visitorData: payload.visitorData, + ...(typeof payload.expiresAt === 'string' + ? { expiresAt: payload.expiresAt } + : {}), + }; +} diff --git a/packages/cli/src/lib/sync.ts b/packages/cli/src/lib/sync.ts index 2fa1f33..d195cf4 100644 --- a/packages/cli/src/lib/sync.ts +++ b/packages/cli/src/lib/sync.ts @@ -581,6 +581,7 @@ function isSkippableYouTubeCandidateError(error: unknown): boolean { return ( message.includes('video is not available') || + message.includes('no valid url to decipher') || message.includes('video is login required') || message.includes('login required') || message.includes('private video') || diff --git a/packages/cli/src/lib/youtube.test.ts b/packages/cli/src/lib/youtube.test.ts index b8493bc..f0a9440 100644 --- a/packages/cli/src/lib/youtube.test.ts +++ b/packages/cli/src/lib/youtube.test.ts @@ -33,10 +33,23 @@ describe('youtube client fallback detection', () => { expect( isRetryableYouTubeClientError(new Error('Streaming data not available')) ).toBe(true); + expect( + isRetryableYouTubeClientError(new Error('No valid URL to decipher')) + ).toBe(true); expect( isRetryableYouTubeClientError( new Error('Playback on other websites has been disabled') ) ).toBe(false); + expect( + isRetryableYouTubeClientError( + new Error('The server responded with a non 2xx status code') + ) + ).toBe(true); + expect( + isRetryableYouTubeClientError( + new Error('YouTube audio stream request failed with status 403.') + ) + ).toBe(true); }); }); diff --git a/packages/cli/src/lib/youtube.ts b/packages/cli/src/lib/youtube.ts index b1d053a..dd8f178 100644 --- a/packages/cli/src/lib/youtube.ts +++ b/packages/cli/src/lib/youtube.ts @@ -5,10 +5,14 @@ import { Readable } from 'node:stream'; import { pipeline } from 'node:stream/promises'; import { promisify } from 'node:util'; import sanitizeFilename from 'sanitize-filename'; -import { Innertube, Log } from 'youtubei.js'; +import { Innertube, Log, Platform } from 'youtubei.js'; import type { AudioFormat, AudioQuality } from './args'; import { ensureFfmpegExecutable } from './ffmpeg'; -import { getYouTubeSessionOptions } from './network'; +import { + getYouTubePoToken, + getYouTubeSessionCacheKey, + getYouTubeSessionOptions, +} from './network'; import type { PlaylistTrack } from './types'; type VideoMatch = { @@ -51,8 +55,10 @@ const execFileAsync = promisify(execFile); const YOUTUBE_SEARCH_RESULT_COUNT = 5; const YOUTUBE_DOWNLOAD_CLIENTS = ['ANDROID', 'MWEB', 'WEB'] as const; let youtubeClientPromise: Promise | null = null; +let youtubeClientSessionKey = ''; Log.setLevel(Log.Level.ERROR); +Platform.shim.eval = async (data) => new Function(data.output)(); export async function searchYoutubeTrack( track: PlaylistTrack @@ -190,10 +196,37 @@ async function downloadWithYoutubeJs(options: { youtubeUrl: string; signal?: AbortSignal; }): Promise { + let lastError: unknown; + + for (const requestClient of YOUTUBE_DOWNLOAD_CLIENTS) { + try { + await downloadWithYoutubeClient(options, [requestClient]); + return; + } catch (error) { + if (!isRetryableYouTubeClientError(error)) throw error; + lastError = error; + } + } + + throw lastError; +} + +async function downloadWithYoutubeClient( + options: { + audioFormat: AudioFormat; + audioQuality: AudioQuality; + destinationPath: string; + onProgress?: (progress: DownloadProgress) => void; + youtubeUrl: string; + signal?: AbortSignal; + }, + requestClients: readonly (typeof YOUTUBE_DOWNLOAD_CLIENTS)[number][] +): Promise { const ffmpegPath = await ensureFfmpegExecutable(); const stream = await resolveAudioStream( options.youtubeUrl, - options.audioFormat + options.audioFormat, + requestClients ); const args = buildFfmpegArgs({ audioFormat: options.audioFormat, @@ -261,18 +294,21 @@ async function downloadWithYoutubeJs(options: { async function resolveAudioStream( youtubeUrl: string, - audioFormat: AudioFormat + audioFormat: AudioFormat, + requestClients: readonly (typeof YOUTUBE_DOWNLOAD_CLIENTS)[number][] = YOUTUBE_DOWNLOAD_CLIENTS ): Promise { const videoId = extractYouTubeVideoId(youtubeUrl); - const client = await getYouTubeClient(); + const poToken = await getYouTubePoToken(videoId).catch(() => undefined); + const client = await getYouTubeClient(poToken); const requestedContainer = getRequestedContainer(audioFormat); let lastError: unknown = null; - for (const requestClient of YOUTUBE_DOWNLOAD_CLIENTS) { + for (const requestClient of requestClients) { const requestOptions = { client: requestClient, type: 'audio' as const, format: requestedContainer, + ...(poToken ? { po_token: poToken.poToken } : {}), }; try { @@ -314,7 +350,10 @@ export function isRetryableYouTubeClientError(error: unknown): boolean { return ( isStreamingDataUnavailableError(error) || - normalizedMessage.includes('login required') + normalizedMessage.includes('login required') || + normalizedMessage.includes('no valid url to decipher') || + normalizedMessage.includes('server responded with a non 2xx status code') || + normalizedMessage.includes('audio stream request failed with status 403') ); } @@ -324,51 +363,59 @@ async function resolveAudioStreamForClient(params: { client: (typeof YOUTUBE_DOWNLOAD_CLIENTS)[number]; type: 'audio'; format: 'mp4' | 'webm'; + po_token?: string; }; requestedContainer: 'mp4' | 'webm'; videoId: string; }): Promise { - try { - const selected = await params.client.getStreamingData( - params.videoId, - params.requestOptions - ); - const downloadStream = await params.client.download( - params.videoId, - params.requestOptions - ); - const mimeType = String(selected.mime_type); - const format = mimeType.includes('webm') ? 'webm' : 'mp4'; + const selected = await params.client.getStreamingData( + params.videoId, + params.requestOptions + ); + const downloadStream = await fetchYouTubeAudioStream(selected.url); + const mimeType = String(selected.mime_type); + const format = mimeType.includes('webm') ? 'webm' : 'mp4'; - return { - contentLength: - typeof selected.content_length === 'number' - ? selected.content_length - : undefined, - format, - mimeType, - stream: downloadStream, - }; - } catch (error) { - if (!isStreamingDataUnavailableError(error)) { - throw error; - } + return { + contentLength: + typeof selected.content_length === 'number' + ? selected.content_length + : undefined, + format, + mimeType, + stream: downloadStream, + }; +} - // Some videos no longer expose decipherable streaming metadata for the - // chosen client, but download() can still provide a readable audio stream. - const downloadStream = await params.client.download( - params.videoId, - params.requestOptions - ); +async function fetchYouTubeAudioStream( + url: string | undefined +): Promise> { + if (!url) throw new Error('YouTube did not provide a deciphered audio URL.'); - return { - contentLength: undefined, - format: params.requestedContainer, - mimeType: - params.requestedContainer === 'webm' ? 'audio/webm' : 'audio/mp4', - stream: downloadStream, - }; + const streamUrl = new URL(url); + streamUrl.searchParams.set( + 'cpn', + crypto.randomUUID().replaceAll('-', '').slice(0, 16) + ); + const response = await fetch(streamUrl, { + headers: { + accept: '*/*', + dnt: '1', + origin: 'https://www.youtube.com', + referer: 'https://www.youtube.com', + }, + }); + + if (!response.ok) { + throw new Error( + `YouTube audio stream request failed with status ${response.status}.` + ); } + + if (!response.body) + throw new Error('YouTube audio stream response had no body.'); + + return response.body; } function buildFfmpegArgs(params: { @@ -522,8 +569,17 @@ function formatBytes(value?: number): string | undefined { return `${Math.round(size * 10) / 10} ${units[unitIndex]}`; } -async function getYouTubeClient(): Promise { - youtubeClientPromise ??= Innertube.create(getYouTubeSessionOptions()); +async function getYouTubeClient(poToken?: { + poToken: string; + visitorData?: string; +}): Promise { + const sessionKey = getYouTubeSessionCacheKey(poToken); + + if (!youtubeClientPromise || youtubeClientSessionKey !== sessionKey) { + youtubeClientSessionKey = sessionKey; + youtubeClientPromise = Innertube.create(getYouTubeSessionOptions(poToken)); + } + return youtubeClientPromise; } diff --git "a/packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" "b/packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" new file mode 100644 index 0000000..f4b9aef --- /dev/null +++ "b/packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" @@ -0,0 +1,9 @@ +{ + "version": 1, + "provider": "spotify", + "playlistId": "2mtlhuFVOFMn6Ho3JmrLc2", + "playlistTitle": "☕️ Programming", + "playlistUrl": "https://open.spotify.com/playlist/2mtlhuFVOFMn6Ho3JmrLc2", + "generatedAt": "2026-06-11T11:44:00.855Z", + "tracks": [] +} diff --git a/packages/e2e/README.md b/packages/e2e/README.md index a1d1396..17a12b6 100644 --- a/packages/e2e/README.md +++ b/packages/e2e/README.md @@ -2,4 +2,8 @@ These tests will run fully e2e test on CLI, it will perform actual downloads a verify files. +Run `bun run test:youtube-smoke` from the repository root to download the +first track of a YouTube Music album and verify that `ffprobe` detects a +non-empty audio stream with a positive duration. + > ⚠️ These tests do not work in CI, the youtube APIs will get blocked. We may be able to work around them using PO Token (Proof of Origin Token) but that requires a server to generate token per Video ID. diff --git a/packages/e2e/package.json b/packages/e2e/package.json index aa27937..21daff9 100644 --- a/packages/e2e/package.json +++ b/packages/e2e/package.json @@ -4,6 +4,7 @@ "private": true, "type": "module", "scripts": { - "test:e2e": "bun test --timeout=60000 --retry=1 --bail --dots" + "test:e2e": "bun test --timeout=60000 --retry=1 --bail --dots", + "test:youtube-smoke": "bun test --timeout=120000 youtube-token-smoke.test.ts" } } diff --git a/packages/e2e/youtube-token-smoke.test.ts b/packages/e2e/youtube-token-smoke.test.ts new file mode 100644 index 0000000..36b248b --- /dev/null +++ b/packages/e2e/youtube-token-smoke.test.ts @@ -0,0 +1,71 @@ +import { expect, test } from 'bun:test'; +import { execFile } from 'node:child_process'; +import { mkdtemp, readdir, rm, stat } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import { runCli } from './utils'; + +const execFileAsync = promisify(execFile); +const SMOKE_TEST_ALBUM_URL = + 'https://music.youtube.com/browse/MPREb_j3iQdYVF98Q'; +const MINIMUM_AUDIO_BYTES = 16 * 1024; + +test('downloads and validates the first YouTube Music track', async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), 'mdl-youtube-smoke-')); + + try { + const cliResult = await runCli([ + '--output', + directory, + '--count', + '1', + SMOKE_TEST_ALBUM_URL, + ]); + const audioPath = await findFirstAudioFile(directory); + + expect(audioPath, cliResult.combinedOutput).toBeTruthy(); + expect((await stat(audioPath as string)).size).toBeGreaterThan( + MINIMUM_AUDIO_BYTES + ); + + const { stdout } = await execFileAsync('ffprobe', [ + '-v', + 'error', + '-select_streams', + 'a:0', + '-show_entries', + 'stream=codec_type:format=duration', + '-of', + 'json', + audioPath as string, + ]); + const probe = JSON.parse(stdout) as { + format?: { duration?: string }; + streams?: Array<{ codec_type?: string }>; + }; + + expect(probe.streams?.[0]?.codec_type).toBe('audio'); + expect(Number(probe.format?.duration)).toBeGreaterThan(0); + } finally { + await rm(directory, { recursive: true, force: true }); + } +}); + +async function findFirstAudioFile(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }); + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + + if (entry.isDirectory()) { + const nestedFile = await findFirstAudioFile(entryPath); + if (nestedFile) return nestedFile; + continue; + } + + if (entry.isFile() && entry.name.endsWith('.mp3')) return entryPath; + } + + return null; +} diff --git a/packages/youtube-token/README.md b/packages/youtube-token/README.md new file mode 100644 index 0000000..399fdf0 --- /dev/null +++ b/packages/youtube-token/README.md @@ -0,0 +1,32 @@ +# YouTube Token Worker + +HTTP PO token provider for the CLI. + +```sh +bun run --filter '@mdl/youtube-token-worker' dev +``` + +The CLI calls: + +```text +GET /token?videoId= +``` + +Responses use the shape expected by `mdl`: + +```json +{ + "poToken": "...", + "visitorData": "...", + "expiresAt": "2026-06-11T12:00:00.000Z" +} +``` + +Tokens are cached in KV for up to one hour per video ID. Cache misses are +limited to 30 token mints per minute in each Cloudflare location to protect the +Browser Rendering quota. + +Important: this uses Cloudflare Browser Rendering via `@cloudflare/puppeteer`, +not plain Worker JavaScript. The browser binding is required because YouTube's +BotGuard flow is expected to run inside a real browser page context. The worker +reuses a browser across warm requests and opens one page per token mint. diff --git a/packages/youtube-token/package.json b/packages/youtube-token/package.json new file mode 100644 index 0000000..c3fe8ce --- /dev/null +++ b/packages/youtube-token/package.json @@ -0,0 +1,21 @@ +{ + "name": "@mdl/youtube-token-worker", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "check": "tsc --noEmit", + "deploy": "wrangler deploy", + "dev": "wrangler dev --remote", + "types": "wrangler types --include-runtime=false", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@cloudflare/puppeteer": "1.1.0", + "bgutils-js": "3.2.0" + }, + "devDependencies": { + "@cloudflare/workers-types": "5.20260710.1", + "wrangler": "4.110.0" + } +} diff --git a/packages/youtube-token/src/index.ts b/packages/youtube-token/src/index.ts new file mode 100644 index 0000000..953b9d6 --- /dev/null +++ b/packages/youtube-token/src/index.ts @@ -0,0 +1,403 @@ +import puppeteer, { + type Browser, + type HTTPRequest, + type Page, +} from '@cloudflare/puppeteer'; +import { BG } from 'bgutils-js'; + +type TokenPayload = { + expiresAt: string; + poToken: string; + visitorData: string; +}; + +type PlayerRequestPayload = { + context?: { client?: { visitorData?: string } }; + serviceIntegrityDimensions?: { poToken?: string }; + [key: string]: unknown; +}; + +type TokenWatcher = { cancel: () => void; promise: Promise }; + +const CACHE_TTL_SECONDS = 60 * 60; +const PLAYER_REQUEST_TIMEOUT_MS = 15_000; + +export default { + async fetch(request: Request, env: Env): Promise { + return handleRequest(request, env).catch((error: unknown) => + json( + { error: error instanceof Error ? error.message : String(error) }, + 500 + ) + ); + }, +}; + +async function handleRequest(request: Request, env: Env): Promise { + const url = new URL(request.url); + + if (url.pathname !== '/token') { + return json({ error: 'Not found' }, 404); + } + + if (request.method !== 'GET') { + return json({ error: 'Method not allowed' }, 405); + } + + const videoId = url.searchParams.get('videoId')?.trim(); + if (!videoId) { + return json({ error: 'Missing videoId query parameter.' }, 400); + } + + const cacheKey = `web-player:${videoId}`; + + const cached = await env.PO_TOKEN_KV.get(cacheKey, 'json'); + if (cached) return json(cached); + + const { success } = await env.TOKEN_MINT_RATE_LIMITER.limit({ + key: 'token-mint', + }); + if (!success) return json({ error: 'Token mint rate limit exceeded.' }, 429); + + const token = await mintToken(videoId, env); + + await env.PO_TOKEN_KV.put(cacheKey, JSON.stringify(token), { + expirationTtl: CACHE_TTL_SECONDS, + }); + + return json(token); +} + +async function mintToken(videoId: string, env: Env): Promise { + console.log('Get a new token for videoId', videoId); + const browser = await getBrowser(env); + const page = await browser.newPage(); + + try { + const tokenWatcher = watchForPlayerToken(page); + + await page.setViewport({ height: 720, width: 1280 }); + await page.goto(`https://www.youtube.com/watch?v=${videoId}`, { + waitUntil: 'domcontentloaded', + }); + await dismissConsent(page); + await page + .locator('.ytp-large-play-button, video, body') + .click() + .catch(() => undefined); + await page.evaluate(() => { + const video = document.querySelector('video'); + void video?.play?.(); + }); + + const pageToken = await waitForTokenFromPage(page); + if (pageToken) { + tokenWatcher.cancel(); + return pageToken; + } + + try { + return await tokenWatcher.promise; + } catch (error) { + const diagnostics = await getPageDiagnostics(page).catch( + (diagnosticError: unknown) => ({ + diagnosticError: + diagnosticError instanceof Error + ? diagnosticError.message + : String(diagnosticError), + }) + ); + const diagnosticToken = tokenFromDiagnostics(diagnostics); + if (diagnosticToken) return diagnosticToken; + + throw new Error( + `${error instanceof Error ? error.message : String(error)} Diagnostics: ${JSON.stringify(diagnostics)}` + ); + } + } finally { + await page.close().catch(() => undefined); + browser.disconnect(); + } +} + +async function waitForTokenFromPage(page: Page): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + const token = await readTokenFromPage(page).catch(() => null); + if (token) return token; + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + return null; +} + +function tokenFromDiagnostics( + diagnostics: Record +): TokenPayload | null { + const getVal = (val: unknown) => (typeof val === 'string' ? val : undefined); + const visitorData = + getVal(diagnostics.visitorDataFromSearch) ?? + getVal(diagnostics.innertubeVisitorData) ?? + getVal(diagnostics.visitorDataFromGet); + + if (!visitorData?.trim()) return null; + + return { + expiresAt: new Date(Date.now() + CACHE_TTL_SECONDS * 1000).toISOString(), + poToken: BG.PoToken.generateColdStartToken(visitorData), + visitorData, + }; +} + +async function getBrowser(env: Env): Promise { + const idleSessions = (await puppeteer.sessions(env.BROWSER)).filter( + (s) => !s.connectionId + ); + if (idleSessions.length) { + return puppeteer.connect(env.BROWSER, idleSessions[0].sessionId); + } + return puppeteer.launch(env.BROWSER, { + keep_alive: 600_000, + }); +} + +async function dismissConsent(page: Page): Promise { + for (const selector of [ + 'button[aria-label="Accept all"]', + 'button[aria-label="Reject all"]', + 'form[action*="consent"] button', + ]) { + const element = await page.$(selector); + if (!element) continue; + + await element.click().catch(() => undefined); + return; + } +} + +async function getPageDiagnostics( + page: Page +): Promise> { + return page.evaluate(() => { + function findStringValue(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined; + + if ( + key in value && + typeof (value as Record)[key] === 'string' + ) { + return (value as Record)[key]; + } + + for (const child of Object.values(value)) { + const result = findStringValue(child, key); + if (result) return result; + } + + return undefined; + } + + const globalScope = window as typeof window & { + ytcfg?: { data_?: unknown; get?: (key: string) => unknown }; + ytInitialPlayerResponse?: unknown; + }; + + return { + bodyText: document.body?.innerText?.slice(0, 500) ?? '', + hasYtcfg: Boolean(globalScope.ytcfg), + hasYtcfgData: Boolean(globalScope.ytcfg?.data_), + hasInitialPlayerResponse: Boolean(globalScope.ytInitialPlayerResponse), + href: location.href, + innertubeVisitorData: findStringValue( + globalScope.ytcfg?.get?.('INNERTUBE_CONTEXT'), + 'visitorData' + ), + performanceUrls: performance + .getEntriesByType('resource') + .map((entry) => entry.name) + .filter((name) => name.includes('youtube') || name.includes('google')) + .slice(-20), + title: document.title, + visitorDataFromGet: + typeof globalScope.ytcfg?.get?.('VISITOR_DATA') === 'string' + ? globalScope.ytcfg.get('VISITOR_DATA') + : null, + visitorDataFromSearch: + findStringValue(globalScope.ytcfg?.data_, 'VISITOR_DATA') ?? + findStringValue(globalScope.ytcfg?.data_, 'visitorData') ?? + null, + ytcfgKeys: + globalScope.ytcfg?.data_ && typeof globalScope.ytcfg.data_ === 'object' + ? Object.keys( + globalScope.ytcfg.data_ as Record + ).slice(0, 30) + : [], + }; + }); +} + +async function readTokenFromPage(page: Page): Promise { + const token = await page.evaluate(() => { + type BrowserTokenPayload = { + poToken?: string; + visitorData?: string; + }; + + function findStringValue(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined; + + if ( + key in value && + typeof (value as Record)[key] === 'string' + ) { + return (value as Record)[key]; + } + + for (const child of Object.values(value)) { + const result = findStringValue(child, key); + if (result) return result; + } + + return undefined; + } + + const globalScope = window as typeof window & { + ytcfg?: { data_?: unknown; get?: (key: string) => unknown }; + ytInitialPlayerResponse?: unknown; + }; + const ytcfgData = + globalScope.ytcfg?.data_ ?? + globalScope.ytcfg?.get?.('INNERTUBE_CONTEXT') ?? + null; + const innertubeContext = globalScope.ytcfg?.get?.('INNERTUBE_CONTEXT'); + const playerResponse = globalScope.ytInitialPlayerResponse ?? null; + const visitorDataFromConfig = globalScope.ytcfg?.get?.('VISITOR_DATA'); + const visitorData = + (typeof visitorDataFromConfig === 'string' + ? visitorDataFromConfig + : undefined) ?? + findStringValue(ytcfgData, 'VISITOR_DATA') ?? + findStringValue(ytcfgData, 'visitorData') ?? + findStringValue(innertubeContext, 'visitorData') ?? + findStringValue(playerResponse, 'visitorData'); + const payload: BrowserTokenPayload = { + poToken: findStringValue(playerResponse, 'poToken'), + visitorData, + }; + + return payload.poToken && payload.visitorData ? payload : null; + }); + + if (!token?.visitorData) return null; + + const poToken = + token.poToken ?? BG.PoToken.generateColdStartToken(token.visitorData); + + return { + expiresAt: new Date(Date.now() + CACHE_TTL_SECONDS * 1000).toISOString(), + poToken, + visitorData: token.visitorData, + }; +} + +function watchForPlayerToken(page: Page): TokenWatcher { + let timeout: ReturnType | undefined; + let isSettled = false; + let requestHandler: ((request: HTTPRequest) => void) | undefined; + + const promise = new Promise((resolve, reject) => { + const seenPlayerRequests: string[] = []; + timeout = setTimeout(() => { + if (requestHandler) page.off('request', requestHandler); + isSettled = true; + reject( + new Error( + `Timed out waiting for YouTube player PO token. Saw ${seenPlayerRequests.length} player request(s): ${seenPlayerRequests.join('; ')}` + ) + ); + }, PLAYER_REQUEST_TIMEOUT_MS); + + requestHandler = (request: HTTPRequest): void => { + if (!request.url().includes('/youtubei/v1/player')) return; + + const payload = parsePlayerRequestPayload(request.postData() ?? null); + const poToken = + payload?.serviceIntegrityDimensions?.poToken ?? + findStringValue(payload, 'poToken'); + const visitorData = + payload?.context?.client?.visitorData ?? + findStringValue(payload, 'visitorData'); + + seenPlayerRequests.push( + JSON.stringify({ + hasPoToken: Boolean(poToken), + hasVisitorData: Boolean(visitorData), + keys: payload ? Object.keys(payload).slice(0, 8) : [], + }) + ); + + if (!poToken || !visitorData) return; + + clearTimeout(timeout); + if (requestHandler) page.off('request', requestHandler); + isSettled = true; + resolve({ + expiresAt: new Date( + Date.now() + CACHE_TTL_SECONDS * 1000 + ).toISOString(), + poToken, + visitorData, + }); + }; + + page.on('request', requestHandler); + }); + + return { + cancel: () => { + if (isSettled) return; + isSettled = true; + if (timeout) clearTimeout(timeout); + if (requestHandler) page.off('request', requestHandler); + }, + promise, + }; +} + +function findStringValue(value: unknown, key: string): string | undefined { + if (!value || typeof value !== 'object') return undefined; + + if ( + key in value && + typeof (value as Record)[key] === 'string' + ) { + return (value as Record)[key]; + } + + for (const child of Object.values(value)) { + const result = findStringValue(child, key); + if (result) return result; + } + + return undefined; +} + +function parsePlayerRequestPayload( + value: string | null +): PlayerRequestPayload | null { + if (!value) return null; + + try { + return JSON.parse(value) as PlayerRequestPayload; + } catch { + return null; + } +} + +function json(value: unknown, status = 200): Response { + return Response.json(value, { + status, + headers: { + 'cache-control': 'no-store', + }, + }); +} diff --git a/packages/youtube-token/tsconfig.json b/packages/youtube-token/tsconfig.json new file mode 100644 index 0000000..3326726 --- /dev/null +++ b/packages/youtube-token/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["@cloudflare/workers-types", "./worker-configuration.d.ts"] + }, + "include": ["src/**/*.ts"] +} diff --git a/packages/youtube-token/worker-configuration.d.ts b/packages/youtube-token/worker-configuration.d.ts new file mode 100644 index 0000000..b75e066 --- /dev/null +++ b/packages/youtube-token/worker-configuration.d.ts @@ -0,0 +1,10 @@ +/* eslint-disable */ +// Generated by Wrangler by running `wrangler types --include-runtime=false` (hash: 8db8f41ade0d026a419b860075f7bcbd) +declare namespace Cloudflare { + interface Env { + PO_TOKEN_KV: KVNamespace; + TOKEN_MINT_RATE_LIMITER: RateLimit; + BROWSER: Fetcher; + } +} +interface Env extends Cloudflare.Env {} diff --git a/packages/youtube-token/wrangler.jsonc b/packages/youtube-token/wrangler.jsonc new file mode 100644 index 0000000..faa1a32 --- /dev/null +++ b/packages/youtube-token/wrangler.jsonc @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/wrangler/config-schema.json", + "name": "mdl-youtube-token", + "main": "src/index.ts", + "account_id": "7bc5bf0e3b7386107e27d74c22b449c0", + "compatibility_date": "2026-04-01", + "compatibility_flags": ["nodejs_compat"], + "workers_dev": true, + "placement": { "mode": "smart" }, + "observability": { "enabled": true }, + "browser": { + "binding": "BROWSER" + }, + "ratelimits": [ + { + "name": "TOKEN_MINT_RATE_LIMITER", + "namespace_id": "760291301", + "simple": { + "limit": 30, + "period": 60 + } + } + ], + "kv_namespaces": [ + { + "id": "2812a928915d4f53af499f6dfd16e0a3", + "preview_id": "728257d93d3d4f46865a7e4d5ff0a237", + "binding": "PO_TOKEN_KV", + } + ] +} From cc5107219dcc508a60326db44925693864614f8c Mon Sep 17 00:00:00 2001 From: Darren Labithiotis Date: Tue, 14 Jul 2026 21:16:10 +0100 Subject: [PATCH 2/7] fix po --- .gitignore | 1 + biome.json | 11 +- packages/cli/package.json | 2 +- packages/cli/src/lib/youtube.ts | 49 +++++--- .../.mdl.json" | 9 -- packages/e2e/utils.ts | 3 +- packages/e2e/youtube-token-smoke.test.ts | 11 +- packages/youtube-token/README.md | 7 +- packages/youtube-token/src/index.ts | 109 +++++++++++++----- 9 files changed, 131 insertions(+), 71 deletions(-) delete mode 100644 "packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" diff --git a/.gitignore b/.gitignore index d87fac5..f70de68 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ music/ .DS_Store *.tsbuildinfo *.tgz +.wrangler diff --git a/biome.json b/biome.json index 5f61dd7..ab58138 100644 --- a/biome.json +++ b/biome.json @@ -5,7 +5,16 @@ }, "files": { "ignoreUnknown": true, - "includes": ["packages/**/*.ts", "packages/**/*.tsx", "*.json", "*.md"] + "includes": [ + "packages/**/*.ts", + "packages/**/*.tsx", + "*.json", + "*.md", + "!**/.wrangler", + "!**/.output", + "!**/dist", + "!**/build" + ] }, "formatter": { "enabled": true, diff --git a/packages/cli/package.json b/packages/cli/package.json index 394fcef..dbd50ec 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -42,7 +42,7 @@ "url": "git+https://github.com/labithiotis/mdl.git" }, "scripts": { - "dev": "bun run src/cli.tsx", + "dev": "MDL_YOUTUBE_TOKEN_PROVIDER_URL=http://localhost:8787/token bun run src/cli.tsx", "build": "bun build --compile --minify ./src/cli.tsx --outfile release/mdl", "build:npm": "bun build --minify --target=node ./src/cli.tsx --outfile release/cli.js", "check": "tsc --noEmit", diff --git a/packages/cli/src/lib/youtube.ts b/packages/cli/src/lib/youtube.ts index dd8f178..af58aa4 100644 --- a/packages/cli/src/lib/youtube.ts +++ b/packages/cli/src/lib/youtube.ts @@ -368,23 +368,38 @@ async function resolveAudioStreamForClient(params: { requestedContainer: 'mp4' | 'webm'; videoId: string; }): Promise { - const selected = await params.client.getStreamingData( - params.videoId, - params.requestOptions - ); - const downloadStream = await fetchYouTubeAudioStream(selected.url); - const mimeType = String(selected.mime_type); - const format = mimeType.includes('webm') ? 'webm' : 'mp4'; - - return { - contentLength: - typeof selected.content_length === 'number' - ? selected.content_length - : undefined, - format, - mimeType, - stream: downloadStream, - }; + try { + const selected = await params.client.getStreamingData( + params.videoId, + params.requestOptions + ); + const downloadStream = await fetchYouTubeAudioStream(selected.url); + const mimeType = String(selected.mime_type); + const format = mimeType.includes('webm') ? 'webm' : 'mp4'; + + return { + contentLength: + typeof selected.content_length === 'number' + ? selected.content_length + : undefined, + format, + mimeType, + stream: downloadStream, + }; + } catch (error) { + if (!isRetryableYouTubeClientError(error)) throw error; + + return { + contentLength: undefined, + format: params.requestedContainer, + mimeType: + params.requestedContainer === 'webm' ? 'audio/webm' : 'audio/mp4', + stream: await params.client.download( + params.videoId, + params.requestOptions + ), + }; + } } async function fetchYouTubeAudioStream( diff --git "a/packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" "b/packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" deleted file mode 100644 index f4b9aef..0000000 --- "a/packages/cli/\342\230\225\357\270\217 Programming/.mdl.json" +++ /dev/null @@ -1,9 +0,0 @@ -{ - "version": 1, - "provider": "spotify", - "playlistId": "2mtlhuFVOFMn6Ho3JmrLc2", - "playlistTitle": "☕️ Programming", - "playlistUrl": "https://open.spotify.com/playlist/2mtlhuFVOFMn6Ho3JmrLc2", - "generatedAt": "2026-06-11T11:44:00.855Z", - "tracks": [] -} diff --git a/packages/e2e/utils.ts b/packages/e2e/utils.ts index 9873ec0..6bba9cf 100644 --- a/packages/e2e/utils.ts +++ b/packages/e2e/utils.ts @@ -35,6 +35,7 @@ export async function runCli( cwd?: string; env?: Record; rejectOnNonZeroExit?: boolean; + timeoutMs?: number; } ): Promise { const cwd = @@ -63,7 +64,7 @@ export async function runCli( stderr += output; }); - const exitCode = await waitForProcessExit(child, CLI_TIMEOUT_MS); + const exitCode = await waitForProcessExit(child, options?.timeoutMs); const result = { command, diff --git a/packages/e2e/youtube-token-smoke.test.ts b/packages/e2e/youtube-token-smoke.test.ts index 36b248b..9494eb6 100644 --- a/packages/e2e/youtube-token-smoke.test.ts +++ b/packages/e2e/youtube-token-smoke.test.ts @@ -15,13 +15,10 @@ test('downloads and validates the first YouTube Music track', async () => { const directory = await mkdtemp(path.join(os.tmpdir(), 'mdl-youtube-smoke-')); try { - const cliResult = await runCli([ - '--output', - directory, - '--count', - '1', - SMOKE_TEST_ALBUM_URL, - ]); + const cliResult = await runCli( + ['--output', directory, '--count', '1', SMOKE_TEST_ALBUM_URL], + { timeoutMs: 119_000 } + ); const audioPath = await findFirstAudioFile(directory); expect(audioPath, cliResult.combinedOutput).toBeTruthy(); diff --git a/packages/youtube-token/README.md b/packages/youtube-token/README.md index 399fdf0..d7e6f2b 100644 --- a/packages/youtube-token/README.md +++ b/packages/youtube-token/README.md @@ -26,7 +26,6 @@ Tokens are cached in KV for up to one hour per video ID. Cache misses are limited to 30 token mints per minute in each Cloudflare location to protect the Browser Rendering quota. -Important: this uses Cloudflare Browser Rendering via `@cloudflare/puppeteer`, -not plain Worker JavaScript. The browser binding is required because YouTube's -BotGuard flow is expected to run inside a real browser page context. The worker -reuses a browser across warm requests and opens one page per token mint. +The worker first bootstraps matching visitor data from YouTube and returns a +cold-start token. Cloudflare Browser Rendering is retained as a fallback for +the full BotGuard flow when session bootstrap is unavailable. diff --git a/packages/youtube-token/src/index.ts b/packages/youtube-token/src/index.ts index 953b9d6..8af53a2 100644 --- a/packages/youtube-token/src/index.ts +++ b/packages/youtube-token/src/index.ts @@ -70,24 +70,31 @@ async function handleRequest(request: Request, env: Env): Promise { async function mintToken(videoId: string, env: Env): Promise { console.log('Get a new token for videoId', videoId); + const coldStartToken = await mintColdStartToken().catch(() => null); + if (coldStartToken) return coldStartToken; + const browser = await getBrowser(env); const page = await browser.newPage(); try { const tokenWatcher = watchForPlayerToken(page); - await page.setViewport({ height: 720, width: 1280 }); - await page.goto(`https://www.youtube.com/watch?v=${videoId}`, { - waitUntil: 'domcontentloaded', - }); - await dismissConsent(page); - await page - .locator('.ytp-large-play-button, video, body') - .click() - .catch(() => undefined); - await page.evaluate(() => { - const video = document.querySelector('video'); - void video?.play?.(); + await runMintStage('setting viewport', () => + page.setViewport({ height: 720, width: 1280 }) + ); + await runMintStage('loading video page', () => + page.goto(`https://www.youtube.com/watch?v=${videoId}`, { + waitUntil: 'domcontentloaded', + }) + ); + await runMintStage('dismissing consent', () => dismissConsent(page)); + await runMintStage('starting playback', async () => { + const playTarget = await page.$('.ytp-large-play-button, video, body'); + await playTarget?.click().catch(() => undefined); + await page.evaluate(() => { + const video = document.querySelector('video'); + void video?.play?.(); + }); }); const pageToken = await waitForTokenFromPage(page); @@ -107,9 +114,6 @@ async function mintToken(videoId: string, env: Env): Promise { : String(diagnosticError), }) ); - const diagnosticToken = tokenFromDiagnostics(diagnostics); - if (diagnosticToken) return diagnosticToken; - throw new Error( `${error instanceof Error ? error.message : String(error)} Diagnostics: ${JSON.stringify(diagnostics)}` ); @@ -120,6 +124,19 @@ async function mintToken(videoId: string, env: Env): Promise { } } +async function runMintStage( + stage: string, + action: () => Promise +): Promise { + try { + return await action(); + } catch (error) { + throw new Error( + `Token mint failed while ${stage}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + async function waitForTokenFromPage(page: Page): Promise { for (let attempt = 0; attempt < 5; attempt += 1) { const token = await readTokenFromPage(page).catch(() => null); @@ -130,24 +147,52 @@ async function waitForTokenFromPage(page: Page): Promise { return null; } -function tokenFromDiagnostics( - diagnostics: Record -): TokenPayload | null { - const getVal = (val: unknown) => (typeof val === 'string' ? val : undefined); - const visitorData = - getVal(diagnostics.visitorDataFromSearch) ?? - getVal(diagnostics.innertubeVisitorData) ?? - getVal(diagnostics.visitorDataFromGet); +async function mintColdStartToken(): Promise { + const visitorId = crypto.randomUUID().replaceAll('-', '').slice(0, 11); + const response = await fetch('https://www.youtube.com/sw.js_data', { + headers: { + accept: '*/*', + 'accept-language': 'en-US', + cookie: `PREF=tz=UTC;VISITOR_INFO1_LIVE=${visitorId};`, + referer: 'https://www.youtube.com/sw.js', + }, + }); + + if (!response.ok) { + throw new Error(`YouTube session bootstrap failed (${response.status}).`); + } - if (!visitorData?.trim()) return null; + const text = await response.text(); + if (!text.startsWith(")]}'")) { + throw new Error('YouTube session bootstrap returned an invalid response.'); + } + + const data: unknown = JSON.parse(text.replace(/^\)\]\}'/, '')); + const visitorData = normalizeVisitorData( + getNestedArrayValue(data, [0, 2, 0, 0, 13]) + ); + if (!visitorData) { + throw new Error('YouTube session bootstrap did not return visitor data.'); + } return { expiresAt: new Date(Date.now() + CACHE_TTL_SECONDS * 1000).toISOString(), - poToken: BG.PoToken.generateColdStartToken(visitorData), + poToken: BG.PoToken.generateColdStartToken(visitorId), visitorData, }; } +function getNestedArrayValue(value: unknown, indexes: number[]): unknown { + let current = value; + + for (const index of indexes) { + if (!Array.isArray(current)) return undefined; + current = current[index]; + } + + return current; +} + async function getBrowser(env: Env): Promise { const idleSessions = (await puppeteer.sessions(env.BROWSER)).filter( (s) => !s.connectionId @@ -215,7 +260,8 @@ async function getPageDiagnostics( .getEntriesByType('resource') .map((entry) => entry.name) .filter((name) => name.includes('youtube') || name.includes('google')) - .slice(-20), + .slice(-10) + .map((name) => name.slice(0, 200)), title: document.title, visitorDataFromGet: typeof globalScope.ytcfg?.get?.('VISITOR_DATA') === 'string' @@ -287,14 +333,11 @@ async function readTokenFromPage(page: Page): Promise { return payload.poToken && payload.visitorData ? payload : null; }); - if (!token?.visitorData) return null; - - const poToken = - token.poToken ?? BG.PoToken.generateColdStartToken(token.visitorData); + if (!token?.poToken || !token.visitorData) return null; return { expiresAt: new Date(Date.now() + CACHE_TTL_SECONDS * 1000).toISOString(), - poToken, + poToken: token.poToken, visitorData: token.visitorData, }; } @@ -381,6 +424,10 @@ function findStringValue(value: unknown, key: string): string | undefined { return undefined; } +function normalizeVisitorData(value: unknown): string | undefined { + return typeof value === 'string' && value.trim() ? value : undefined; +} + function parsePlayerRequestPayload( value: string | null ): PlayerRequestPayload | null { From 6f7a660f6265a160b4a895094b4f8759f6b77123 Mon Sep 17 00:00:00 2001 From: Darren Labithiotis Date: Wed, 15 Jul 2026 21:21:52 +0100 Subject: [PATCH 3/7] big clean up and got po working --- AGENTS.md | 35 +- biome.json | 3 +- bun.lock | 44 +- docs/CAVEMAN.md | 26 + docs/GH_WORKFLOWS.md | 22 + docs/PULL_REQUESTS.md | 11 + docs/SHELL.md | 30 + docs/TESTING.md | 15 + docs/TYPESCRIPT.md | 26 + package.json | 4 +- packages/cli/README.md | 6 + packages/cli/src/cli.tsx | 5 +- packages/cli/src/lib/args.test.ts | 26 +- packages/cli/src/lib/args.ts | 99 +-- packages/cli/src/lib/ffmpeg.ts | 14 +- packages/cli/src/lib/installation.test.ts | 42 ++ packages/cli/src/lib/installation.ts | 77 +++ packages/cli/src/lib/manifest.test.ts | 11 +- packages/cli/src/lib/manifest.ts | 21 +- packages/cli/src/lib/metadata.ts | 26 +- packages/cli/src/lib/network.test.ts | 70 ++- packages/cli/src/lib/network.ts | 80 ++- .../cli/src/lib/providers/AmazonMusic.test.ts | 3 +- packages/cli/src/lib/providers/AmazonMusic.ts | 186 ++---- .../cli/src/lib/providers/AppleMusic.test.ts | 21 +- packages/cli/src/lib/providers/AppleMusic.ts | 105 +--- .../cli/src/lib/providers/Bandcamp.test.ts | 31 +- packages/cli/src/lib/providers/Bandcamp.ts | 116 +--- packages/cli/src/lib/providers/Deezer.ts | 66 +- .../cli/src/lib/providers/Providers.test.ts | 194 ++---- packages/cli/src/lib/providers/Providers.ts | 36 +- packages/cli/src/lib/providers/Qobuz.ts | 48 +- packages/cli/src/lib/providers/SoundCloud.ts | 60 +- .../cli/src/lib/providers/Soundcloud.test.ts | 13 +- .../cli/src/lib/providers/Spotify.test.ts | 38 +- packages/cli/src/lib/providers/Spotify.ts | 123 +--- packages/cli/src/lib/providers/Tidal.test.ts | 24 +- packages/cli/src/lib/providers/Tidal.ts | 104 +--- .../src/lib/providers/YouTubeMusic.test.ts | 34 +- .../cli/src/lib/providers/YouTubeMusic.ts | 67 +-- packages/cli/src/lib/sync.test.ts | 97 ++- packages/cli/src/lib/sync.ts | 563 ++++++++---------- packages/cli/src/lib/types.ts | 4 +- packages/cli/src/lib/utils.test.ts | 16 +- packages/cli/src/lib/utils.ts | 37 +- packages/cli/src/lib/youtube.test.ts | 44 +- packages/cli/src/lib/youtube.ts | 106 +--- packages/cli/src/ui/DoneScreen.tsx | 22 +- packages/cli/src/ui/InputScreen.tsx | 21 +- packages/cli/src/ui/LoadingScreen.tsx | 9 +- packages/cli/src/ui/SyncScreen.tsx | 28 +- packages/cli/src/ui/app.tsx | 81 +-- packages/cli/src/ui/components/CarbonAd.tsx | 21 +- packages/cli/src/ui/components/Hero.tsx | 8 +- packages/cli/src/ui/components/ListPanel.tsx | 30 +- packages/cli/src/ui/components/Panel.tsx | 16 +- packages/cli/src/ui/components/UrlInput.tsx | 26 +- packages/cli/src/ui/components/WorkerRow.tsx | 13 +- packages/cli/src/ui/state.test.ts | 3 +- packages/cli/src/ui/state.ts | 28 +- packages/cli/src/ui/utils/urlInput.test.ts | 16 +- packages/cli/src/ui/utils/workerSlots.ts | 10 +- packages/e2e/cli-provider.test.ts | 25 +- packages/e2e/utils.ts | 17 +- packages/e2e/youtube-token-smoke.test.ts | 14 +- packages/web/src/components/Demo.tsx | 15 +- packages/web/src/components/Features.tsx | 41 +- packages/web/src/components/Footer.tsx | 11 +- packages/web/src/components/Header.tsx | 26 +- packages/web/src/components/Hero.tsx | 11 +- packages/web/src/components/Install.tsx | 26 +- packages/web/src/components/Providers.tsx | 25 +- packages/youtube-token/package.json | 3 +- packages/youtube-token/src/index.ts | 451 +------------- packages/youtube-token/src/rateLimit.test.ts | 56 ++ packages/youtube-token/src/rateLimit.ts | 83 +++ packages/youtube-token/src/tokenMint.ts | 287 +++++++++ packages/youtube-token/src/tokenRequest.ts | 97 +++ packages/youtube-token/tsconfig.json | 2 +- .../youtube-token/worker-configuration.d.ts | 17 +- packages/youtube-token/wrangler.jsonc | 18 +- 81 files changed, 1809 insertions(+), 2577 deletions(-) create mode 100644 docs/CAVEMAN.md create mode 100644 docs/GH_WORKFLOWS.md create mode 100644 docs/PULL_REQUESTS.md create mode 100644 docs/SHELL.md create mode 100644 docs/TESTING.md create mode 100644 docs/TYPESCRIPT.md create mode 100644 packages/cli/src/lib/installation.test.ts create mode 100644 packages/cli/src/lib/installation.ts create mode 100644 packages/youtube-token/src/rateLimit.test.ts create mode 100644 packages/youtube-token/src/rateLimit.ts create mode 100644 packages/youtube-token/src/tokenMint.ts create mode 100644 packages/youtube-token/src/tokenRequest.ts diff --git a/AGENTS.md b/AGENTS.md index e51297c..48fc642 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,25 +1,20 @@ -You are an expert in TypeScript, Node.js, React, Tailwind CSS. +Use `./docs/CAVEMAN.md` for thinking and responses. +Env vars via Varlock (+ GCP Secret Manager). +New dependencies use the latest compatible release; the repo pins exact versions. +Use Conventional Commits for branches and commits. Subject under ~70 chars; body only when the why matters. -## Code Style and Structure +## Routing -Write concise, technical TypeScript code with accurate examples. -Use descriptive variable names with auxiliary verbs (e.g., isLoading, hasError). -Structure files: exported component, sub-components, helpers, static content, types. -Keep inline comments concise and limited for overlay complex code, favour using debug logging to explain what code is doing. Avoid adding huge chunks of commented code explaining the code. +Load the smallest relevant doc set for the task: -## Naming Conventions +- Open `./docs/TYPESCRIPT.md` only when editing TypeScript or JavaScript files. +- Open `./docs/TESTING.md` only when editing tests, mocks, or test infra. +- Open `./docs/SHELL.md` only when editing shell scripts or `run` files. +- Open `./docs/GH_WORKFLOWS.md` only when editing `.github/workflows/*`. -Use lowercase for directories and Capitalization only for Class constructor or Components (e.g., components/AuthWizard), for helpers/lib/utils use lowercase. -Favor named exports for components. +## Naming -## TypeScript Usage - -Use TypeScript for all code; prefer types over interfaces. -Avoid enums; use maps instead. -Use functional components with TypeScript types. - -## Syntax and Formatting - -Use the "function" keyword for pure functions. -Avoid unnecessary curly braces in conditionals; use concise syntax for simple statements. -Use declarative JSX. +- `camelCase` for directories, files, and the default fallback. +- `PascalCase` for React components and class constructor files. +- `UPPER_SNAKE_CASE` for markdown files. +- Preserve framework-required route filenames. diff --git a/biome.json b/biome.json index ab58138..96b1ca1 100644 --- a/biome.json +++ b/biome.json @@ -18,7 +18,8 @@ }, "formatter": { "enabled": true, - "indentStyle": "space" + "indentStyle": "space", + "lineWidth": 120 }, "linter": { "enabled": true, diff --git a/bun.lock b/bun.lock index d630b8f..816d357 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@biomejs/biome": "2.4.8", "@types/bun": "latest", "lefthook": "2.1.5", - "typescript": "6.0.2", + "typescript": "7.0.2", }, }, "packages/cli": { @@ -384,6 +384,46 @@ "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@typescript/typescript-aix-ppc64": ["@typescript/typescript-aix-ppc64@7.0.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ=="], + + "@typescript/typescript-darwin-arm64": ["@typescript/typescript-darwin-arm64@7.0.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA=="], + + "@typescript/typescript-darwin-x64": ["@typescript/typescript-darwin-x64@7.0.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA=="], + + "@typescript/typescript-freebsd-arm64": ["@typescript/typescript-freebsd-arm64@7.0.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ=="], + + "@typescript/typescript-freebsd-x64": ["@typescript/typescript-freebsd-x64@7.0.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw=="], + + "@typescript/typescript-linux-arm": ["@typescript/typescript-linux-arm@7.0.2", "", { "os": "linux", "cpu": "arm" }, "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ=="], + + "@typescript/typescript-linux-arm64": ["@typescript/typescript-linux-arm64@7.0.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ=="], + + "@typescript/typescript-linux-loong64": ["@typescript/typescript-linux-loong64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ=="], + + "@typescript/typescript-linux-mips64el": ["@typescript/typescript-linux-mips64el@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA=="], + + "@typescript/typescript-linux-ppc64": ["@typescript/typescript-linux-ppc64@7.0.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA=="], + + "@typescript/typescript-linux-riscv64": ["@typescript/typescript-linux-riscv64@7.0.2", "", { "os": "linux", "cpu": "none" }, "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ=="], + + "@typescript/typescript-linux-s390x": ["@typescript/typescript-linux-s390x@7.0.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw=="], + + "@typescript/typescript-linux-x64": ["@typescript/typescript-linux-x64@7.0.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A=="], + + "@typescript/typescript-netbsd-arm64": ["@typescript/typescript-netbsd-arm64@7.0.2", "", { "os": "none", "cpu": "arm64" }, "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA=="], + + "@typescript/typescript-netbsd-x64": ["@typescript/typescript-netbsd-x64@7.0.2", "", { "os": "none", "cpu": "x64" }, "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA=="], + + "@typescript/typescript-openbsd-arm64": ["@typescript/typescript-openbsd-arm64@7.0.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ=="], + + "@typescript/typescript-openbsd-x64": ["@typescript/typescript-openbsd-x64@7.0.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg=="], + + "@typescript/typescript-sunos-x64": ["@typescript/typescript-sunos-x64@7.0.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g=="], + + "@typescript/typescript-win32-arm64": ["@typescript/typescript-win32-arm64@7.0.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ=="], + + "@typescript/typescript-win32-x64": ["@typescript/typescript-win32-x64@7.0.2", "", { "os": "win32", "cpu": "x64" }, "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g=="], + "@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="], "acorn": ["acorn@8.14.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA=="], @@ -752,7 +792,7 @@ "type-fest": ["type-fest@5.5.0", "", { "dependencies": { "tagged-tag": "1.0.0" } }, "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g=="], - "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], + "typescript": ["typescript@7.0.2", "", { "optionalDependencies": { "@typescript/typescript-aix-ppc64": "7.0.2", "@typescript/typescript-darwin-arm64": "7.0.2", "@typescript/typescript-darwin-x64": "7.0.2", "@typescript/typescript-freebsd-arm64": "7.0.2", "@typescript/typescript-freebsd-x64": "7.0.2", "@typescript/typescript-linux-arm": "7.0.2", "@typescript/typescript-linux-arm64": "7.0.2", "@typescript/typescript-linux-loong64": "7.0.2", "@typescript/typescript-linux-mips64el": "7.0.2", "@typescript/typescript-linux-ppc64": "7.0.2", "@typescript/typescript-linux-riscv64": "7.0.2", "@typescript/typescript-linux-s390x": "7.0.2", "@typescript/typescript-linux-x64": "7.0.2", "@typescript/typescript-netbsd-arm64": "7.0.2", "@typescript/typescript-netbsd-x64": "7.0.2", "@typescript/typescript-openbsd-arm64": "7.0.2", "@typescript/typescript-openbsd-x64": "7.0.2", "@typescript/typescript-sunos-x64": "7.0.2", "@typescript/typescript-win32-arm64": "7.0.2", "@typescript/typescript-win32-x64": "7.0.2" }, "bin": { "tsc": "bin/tsc" } }, "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA=="], "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], diff --git a/docs/CAVEMAN.md b/docs/CAVEMAN.md new file mode 100644 index 0000000..7e025e2 --- /dev/null +++ b/docs/CAVEMAN.md @@ -0,0 +1,26 @@ +Respond terse. Keep full technical meaning. Cut fluff. + +Persistent default for every response. No filler drift. + +## Rules + +Drop articles, filler, pleasantries, and hedging. Fragments OK. Use short words when meaning stays exact. Keep technical terms exact. Keep code blocks and error text exact. + +Pattern: `[thing] [action] [reason]. [next step].` + +Use compact forms when clear: `DB`, `auth`, `config`, `req`, `res`, `fn`, `impl`. Prefer arrows for causality: `X → Y`. + +## Auto-Clarity + +Temporarily drop caveman mode for: + +- security warnings +- irreversible actions +- multi-step instructions where order could be misread +- explicit requests for clarification + +Resume terse mode after the clear section. + +## Boundaries + +Code, commits, and PR text stay normal. diff --git a/docs/GH_WORKFLOWS.md b/docs/GH_WORKFLOWS.md new file mode 100644 index 0000000..abbe62b --- /dev/null +++ b/docs/GH_WORKFLOWS.md @@ -0,0 +1,22 @@ +If you touch a workflow and an existing action used in the repo is stale, upgrade it and keep versions consistent across workflows. +For new actions, use the current stable major. +Do not set default values for actions in env/with, rely on default values being set. + +Order keys in steps like this: + +1. id +2. name +3. working-directory +4. env +5. run + +Example: + +```yaml +- id: deploy + name: Deploy + working-directory: apps/api + env: + APP_ENV: beta + run: ./run deploy:${APP_ENV} +``` diff --git a/docs/PULL_REQUESTS.md b/docs/PULL_REQUESTS.md new file mode 100644 index 0000000..283f341 --- /dev/null +++ b/docs/PULL_REQUESTS.md @@ -0,0 +1,11 @@ +# Pull Requests + +## Size + +Keep PRs small. Soft target: **<= 200 lines net diff, <= 10 files** (lockfile + generated artifacts excluded). **Hard split** if the planned slice heads past **1,000 lines or 35 files**: break the deliverable into smaller PRs that ship one at a time. Reviewer fatigue sets in around 400 lines; if the review needs more than one sitting, the PR is too big. + +If the work does not naturally split, the slice goal is too big. Re-plan instead of pushing through. Mechanical-only PRs (renames, dependency bumps, generated-file regen, doc moves) can go larger; flag that in the title or first line. + +## Task Chunking + +Plan in the smallest shippable units. When breaking down work, prefer one concern per branch: multiple small PRs over one bundle. Use sequential PRs where logic requires order; use parallel PRs where slices are independent. diff --git a/docs/SHELL.md b/docs/SHELL.md new file mode 100644 index 0000000..faf836b --- /dev/null +++ b/docs/SHELL.md @@ -0,0 +1,30 @@ +When doing run args please follow this pattern. Where a function called runCommands is at the top always and it defines what other function are called based on args. + +```bash +#!/usr/bin/env bash +set -euo pipefail + +runCommands() { + if [[ "${1}" == "command1" ]]; then + command1 + elif [[ "${1}" == "command2" ]]; then + command2 + else + echo "Please provide one of the following commands: command1, command2" + exit 1 + fi +} + +command1() { + # code +} + +command2() { + # code +} + +runCommands "${@}" +``` + ++Inject variables into strings like this `"${VAR}"`. +Avoid hasty abstraction, if var is only ref once and it's not too long, then inline the var. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..e359c3a --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,15 @@ +Use bun's test runner across the repo. + +- Prefer behavior tests through public interfaces. +- Mock system boundaries, not your own modules, unless the seam is truly external or non-deterministic. +- No real network in tests. + +## Tooling + +- Use MSW for fetch and HTTP mocking. + +## Picking a seam + +- Use unit tests for pure logic. +- Use integration tests for request, data, and persistence flows. +- Use E2E only for critical UI journeys that cannot be proven lower in the stack. diff --git a/docs/TYPESCRIPT.md b/docs/TYPESCRIPT.md new file mode 100644 index 0000000..a345d48 --- /dev/null +++ b/docs/TYPESCRIPT.md @@ -0,0 +1,26 @@ +## Code style + +- Prefer functional and declarative patterns. +- "AHA (Avoid Hasty Abstractions) and KISS (Keep It Simple, Stupid). Prefer duplication over premature abstraction; prefer simple, direct code over clever indirection. Don't extract one- or two-line helpers, inline them. +- Use `function` for top-level declarations; use arrow functions for callbacks and inline expressions. +- Avoid enums; prefer unions, maps, or objects. +- Keep inline comments rare and concise. + +## Types + +- Prefer `type` over `interface`, except for class contracts. +- Default to mutable types. Use `readonly` only for exported types consumers shouldn't mutate, or for frozen config. +- Prefix Zod schemas with `z` (e.g., `zUser`) so the inferred type can take the clean name: `type User = z.infer`. + +## Imports & exports + +- Use named exports. +- Follow Biome import ordering. +- Use relative imports within one directory. Use `~` aliases for deeper `src/` imports. +- Route files do not use default exports. + +## Project conventions + +- Use `void` only when intentionally discarding a non-void result. +- Prefer descriptive but compact names. Shorten long suffixes like `Opts` or `Args` when the full name gets noisy. +- If a config option has a safe default, omit it. diff --git a/package.json b/package.json index cea3bc0..e273eb9 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ ], "scripts": { "prepare": "lefthook install --reset-hooks-path", - "fmt": "biome format --write .", + "format": "biome format --write .", "lint": "biome check .", "lint:fix": "biome check --write .", "check": "bun run typecheck", @@ -18,7 +18,7 @@ "@biomejs/biome": "2.4.8", "@types/bun": "latest", "lefthook": "2.1.5", - "typescript": "6.0.2" + "typescript": "7.0.2" }, "packageManager": "bun@1.3.10", "knip": { diff --git a/packages/cli/README.md b/packages/cli/README.md index e6455ae..8074f59 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -59,6 +59,12 @@ Choose the extracted audio format and quality: mdl "https://open.spotify.com/playlist/..." --format m4a --bitrate 192K ``` +Skip requesting YouTube Proof of Origin tokens: + +```bash +mdl "https://open.spotify.com/playlist/..." --no-po-token +``` + CLI help: ```bash diff --git a/packages/cli/src/cli.tsx b/packages/cli/src/cli.tsx index 80d4cbe..38e245a 100644 --- a/packages/cli/src/cli.tsx +++ b/packages/cli/src/cli.tsx @@ -15,12 +15,11 @@ try { const args = parseCliArgs(process.argv.slice(2)); configureNetwork({ proxy: args.proxy, + usePoToken: args.usePoToken, ytCookie: args.ytCookie, ytUserAgent: args.ytUserAgent, }); - const dir = path.resolve( - process.env.INIT_CWD ?? process.env.PWD ?? process.cwd() - ); + const dir = path.resolve(process.env.INIT_CWD ?? process.env.PWD ?? process.cwd()); const manifest = !args.url ? await loadManifest(dir) : null; const outputDir = path.resolve(args.outputDir ?? dir); diff --git a/packages/cli/src/lib/args.test.ts b/packages/cli/src/lib/args.test.ts index 90249b5..80b8390 100644 --- a/packages/cli/src/lib/args.test.ts +++ b/packages/cli/src/lib/args.test.ts @@ -11,6 +11,7 @@ describe('args', () => { audioFormat: 'mp3', audioQuality: 'best', downloadParallelism: 10, + usePoToken: true, }); }); @@ -29,6 +30,7 @@ describe('args', () => { audioFormat: 'mp3', audioQuality: 'best', downloadParallelism: 7, + usePoToken: true, trackCount: 3, outputDir: './music', url: 'https://open.spotify.com/playlist/example', @@ -49,10 +51,10 @@ describe('args', () => { audioFormat: 'mp3', audioQuality: 'best', downloadParallelism: 10, + usePoToken: true, proxy: 'https://user:pass@dc.oxylabs.io:8000/', ytCookie: 'SID=abc; HSID=def', - ytUserAgent: - 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0', + ytUserAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:149.0) Gecko/20100101 Firefox/149.0', }); }); @@ -61,6 +63,13 @@ describe('args', () => { audioFormat: 'm4a', audioQuality: '192K', downloadParallelism: 10, + usePoToken: true, + }); + }); + + test('parseCliArgs can disable YouTube PO tokens', () => { + expect(parseCliArgs(['--no-po-token'])).toMatchObject({ + usePoToken: false, }); }); @@ -115,19 +124,12 @@ describe('args', () => { expect(() => { parseCliArgs(['--version']); }).toThrow('exit:0'); - expect(stdoutWrite).toHaveBeenCalledWith( - expect.stringMatching(/\d+\.\d+\.\d+/) - ); + expect(stdoutWrite).toHaveBeenCalledWith(expect.stringMatching(/\d+\.\d+\.\d+/)); }); test('parseCliArgs rejects unexpected arguments', () => { expect(() => { - parseCliArgs([ - 'https://open.spotify.com/playlist/example', - 'unexpected-token', - ]); - }).toThrow( - 'Unexpected argument "unexpected-token". Please use one of these:' - ); + parseCliArgs(['https://open.spotify.com/playlist/example', 'unexpected-token']); + }).toThrow('Unexpected argument "unexpected-token". Please use one of these:'); }); }); diff --git a/packages/cli/src/lib/args.ts b/packages/cli/src/lib/args.ts index 2290ee4..b0c7594 100644 --- a/packages/cli/src/lib/args.ts +++ b/packages/cli/src/lib/args.ts @@ -39,6 +39,7 @@ export type CliOptions = DownloadCliOptions & { trackCount?: number; url?: string; ytCookie?: string; + usePoToken: boolean; ytUserAgent?: string; }; @@ -49,6 +50,7 @@ type ParsedCommanderOptions = { output?: string; parallel: number; proxy?: string; + poToken: boolean; ytCookie?: string; ytUserAgent?: string; }; @@ -66,10 +68,7 @@ export function parseCliArgs(argv: string[]): CliOptions { program.parse(argv, { from: 'user' }); } catch (error) { if (error instanceof CommanderError) { - if ( - error.code === 'commander.helpDisplayed' || - error.code === 'commander.version' - ) { + if (error.code === 'commander.helpDisplayed' || error.code === 'commander.version') { process.exit(0); } @@ -86,6 +85,7 @@ export function parseCliArgs(argv: string[]): CliOptions { audioFormat: options.format, audioQuality: options.bitrate, downloadParallelism: options.parallel, + usePoToken: options.poToken, ...(options.output ? { outputDir: options.output } : {}), ...(options.proxy ? { proxy: options.proxy } : {}), ...(typeof options.count === 'number' ? { trackCount: options.count } : {}), @@ -98,34 +98,24 @@ export function parseCliArgs(argv: string[]): CliOptions { function createCliProgram(): Command { return new Command() .name('mdl') - .description( - 'Interactive playlist and album downloader for streaming collections -> YouTube audio.' - ) + .description('Interactive playlist and album downloader for streaming collections -> YouTube audio.') .usage('[playlist-or-album-url] [options]') .helpOption('-h, --help', 'Show this help message.') .version(packageJson.version, '-v, --version', 'Show the current version.') .argument('[playlist-or-album-url]', 'Playlist or album URL.') - .addOption( - new Option( - '-o, --output ', - 'Base output directory. Playlist files are stored in a subfolder.' - ) - ) + .addOption(new Option('-o, --output ', 'Base output directory. Playlist files are stored in a subfolder.')) .addOption( new Option( '-p, --parallel ', `Number of tracks to download in parallel. Default: ${DEFAULT_DOWNLOAD_PARALLELISM}.` ) .default(DEFAULT_DOWNLOAD_PARALLELISM) - .argParser((value: string) => - parsePositiveIntegerArg(value, '--parallel') - ) + .argParser((value: string) => parsePositiveIntegerArg(value, '--parallel')) ) .addOption( - new Option( - '-c, --count ', - 'Maximum number of tracks to download from the collection.' - ).argParser((value: string) => parsePositiveIntegerArg(value, '--count')) + new Option('-c, --count ', 'Maximum number of tracks to download from the collection.').argParser( + (value: string) => parsePositiveIntegerArg(value, '--count') + ) ) .addOption( new Option( @@ -141,33 +131,18 @@ function createCliProgram(): Command { `Output audio quality. Use best, 0-10 VBR, or ${AUDIO_QUALITIES.filter((quality) => quality.endsWith('K')).join(', ')}. Default: ${DEFAULT_AUDIO_QUALITY}.` ) .default(DEFAULT_AUDIO_QUALITY) - .argParser((value: string) => - parseAudioQualityArg(normalizeAudioQualityArg(value), '--bitrate') - ) - ) - .addOption( - new Option( - '--proxy ', - 'HTTPS/HTTP proxy URL used for provider fetches and YouTube requests.' - ).argParser((value: string) => parseProxyArg(value)) + .argParser((value: string) => parseAudioQualityArg(normalizeAudioQualityArg(value), '--bitrate')) ) + .addOption(new Option('--no-po-token', 'Do not request YouTube Proof of Origin tokens.')) .addOption( - new Option( - '--yt-cookie ', - 'YouTube Cookie used for YouTube requests.' - ) - ) - .addOption( - new Option( - '--yt-user-agent ', - 'YouTube User-Agent header used for YouTube requests.' + new Option('--proxy ', 'HTTPS/HTTP proxy URL used for provider fetches and YouTube requests.').argParser( + (value: string) => parseProxyArg(value) ) ) + .addOption(new Option('--yt-cookie ', 'YouTube Cookie used for YouTube requests.')) + .addOption(new Option('--yt-user-agent ', 'YouTube User-Agent header used for YouTube requests.')) .addHelpText('before', 'mdl (MusicDownLoader)\n\n') - .addHelpText( - 'after', - `\nProviders:\n ${PROVIDERS.join(', ')}.\n Audio downloads currently come from YouTube.\n` - ) + .addHelpText('after', `\nProviders:\n ${PROVIDERS.join(', ')}.\n Audio downloads currently come from YouTube.\n`) .exitOverride() .configureOutput({ writeOut: (str: string) => process.stdout.write(str), @@ -178,12 +153,7 @@ function createCliProgram(): Command { function parsePositiveIntegerArg(value: string, flagName: string): number { const parsed = Number.parseInt(value, 10); - if ( - !Number.isInteger(parsed) || - Number.isNaN(parsed) || - parsed <= 0 || - parsed.toString() !== value - ) { + if (!Number.isInteger(parsed) || Number.isNaN(parsed) || parsed <= 0 || parsed.toString() !== value) { throw new CliArgumentError({ message: `${flagName} must be a positive integer.`, }); @@ -204,29 +174,19 @@ function parseAudioFormatArg(value: string, flagName: string): AudioFormat { }); } -function normalizeCommanderError( - program: Command, - error: CommanderError, - argv: string[] -): Error { +function normalizeCommanderError(program: Command, error: CommanderError, argv: string[]): Error { if (error.code === 'commander.excessArguments') { const positionalArgs = argv.filter((token) => !token.startsWith('-')); const unexpectedArg = positionalArgs[1]; return new CliArgumentError({ - message: formatUnexpectedArgumentMessage( - program, - unexpectedArg ?? error.message - ), + message: formatUnexpectedArgumentMessage(program, unexpectedArg ?? error.message), }); } if (error.code === 'commander.unknownOption') { const unexpectedToken = argv.find((token) => token.startsWith('-')); return new CliArgumentError({ - message: formatUnexpectedArgumentMessage( - program, - unexpectedToken ?? error.message - ), + message: formatUnexpectedArgumentMessage(program, unexpectedToken ?? error.message), }); } @@ -237,17 +197,13 @@ function normalizeCommanderError( } if (error.code === 'commander.optionMissingArgument') { - const matchedFlags = error.message.match( - /option '([^']+)' argument missing/ - ); + const matchedFlags = error.message.match(/option '([^']+)' argument missing/); const normalizedFlag = matchedFlags?.[1] ?.split(',') .map((value) => value.trim().split(' ')[0]) .find((value) => value.startsWith('--')) ?? - matchedFlags?.[1] - ?.split(',') - .map((value) => value.trim().split(' ')[0])[0] ?? + matchedFlags?.[1]?.split(',').map((value) => value.trim().split(' ')[0])[0] ?? '--option'; return new CliArgumentError({ @@ -270,15 +226,10 @@ function parseAudioQualityArg(value: string, flagName: string): AudioQuality { }); } -function formatUnexpectedArgumentMessage( - program: Command, - argument: string -): string { +function formatUnexpectedArgumentMessage(program: Command, argument: string): string { const help = program.createHelp(); const supportedArgs = [ - ...program.registeredArguments.map((registeredArgument) => - help.argumentTerm(registeredArgument) - ), + ...program.registeredArguments.map((registeredArgument) => help.argumentTerm(registeredArgument)), ...help.visibleOptions(program).map((option) => help.optionTerm(option)), ] .map((value) => ` ${value}`) diff --git a/packages/cli/src/lib/ffmpeg.ts b/packages/cli/src/lib/ffmpeg.ts index 09e4046..bd701ea 100644 --- a/packages/cli/src/lib/ffmpeg.ts +++ b/packages/cli/src/lib/ffmpeg.ts @@ -9,28 +9,20 @@ export async function ensureFfmpegExecutable(): Promise { const ffmpegPath = await findBinaryPath('ffmpeg'); if (!ffmpegPath) { - throw new Error( - 'ffmpeg is required but was not found on PATH. Install ffmpeg and try again.' - ); + throw new Error('ffmpeg is required but was not found on PATH. Install ffmpeg and try again.'); } return ffmpegPath; } async function findBinaryPath(binaryName: string): Promise { - const pathEntries = (process.env.PATH ?? '') - .split(path.delimiter) - .filter(Boolean); + const pathEntries = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean); const commonEntries = process.platform === 'darwin' ? ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin'] : ['/usr/local/bin', '/usr/bin', '/bin']; - for (const candidate of new Set( - [...pathEntries, ...commonEntries].map((entry) => - path.join(entry, binaryName) - ) - )) { + for (const candidate of new Set([...pathEntries, ...commonEntries].map((entry) => path.join(entry, binaryName)))) { try { await access(candidate); return candidate; diff --git a/packages/cli/src/lib/installation.test.ts b/packages/cli/src/lib/installation.test.ts new file mode 100644 index 0000000..8660068 --- /dev/null +++ b/packages/cli/src/lib/installation.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, test } from 'bun:test'; +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { loadInstallationId } from './installation'; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); +}); + +describe('installation ID', () => { + test('creates and reuses a UUID', async () => { + const directory = await createTemporaryDirectory(); + const installationId = await loadInstallationId(directory); + + expect(installationId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i); + expect(await loadInstallationId(directory)).toBe(installationId); + expect(JSON.parse(await readFile(path.join(directory, 'config.json'), 'utf8'))).toEqual({ installationId }); + }); + + test('replaces malformed configuration', async () => { + const directory = await createTemporaryDirectory(); + await writeFile(path.join(directory, 'config.json'), '{broken'); + + await expect(loadInstallationId(directory)).resolves.toMatch(/^[0-9a-f-]{36}$/i); + }); + + test('shares one UUID across concurrent first-run processes', async () => { + const directory = await createTemporaryDirectory(); + const installationIds = await Promise.all(Array.from({ length: 10 }, () => loadInstallationId(directory))); + + expect(new Set(installationIds).size).toBe(1); + }); +}); + +async function createTemporaryDirectory(): Promise { + const directory = await mkdtemp(path.join(os.tmpdir(), 'mdl-installation-')); + temporaryDirectories.push(directory); + return directory; +} diff --git a/packages/cli/src/lib/installation.ts b/packages/cli/src/lib/installation.ts new file mode 100644 index 0000000..76c3470 --- /dev/null +++ b/packages/cli/src/lib/installation.ts @@ -0,0 +1,77 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +type InstallationConfig = { + installationId: string; +}; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +let installationIdPromise: Promise | null = null; + +export function getInstallationId(): Promise { + installationIdPromise ??= loadInstallationId(getConfigDirectory()); + return installationIdPromise; +} + +export async function loadInstallationId(configDirectory: string): Promise { + const configPath = path.join(configDirectory, 'config.json'); + const existingInstallationId = await readInstallationId(configPath); + if (existingInstallationId) return existingInstallationId; + + const installationId = crypto.randomUUID().toLowerCase(); + await mkdir(configDirectory, { recursive: true, mode: 0o700 }); + + try { + await writeInstallationId(configPath, installationId, 'wx'); + } catch (error) { + if (!isFileExistsError(error)) throw error; + + const winningInstallationId = await waitForInstallationId(configPath); + if (winningInstallationId) return winningInstallationId; + await writeInstallationId(configPath, installationId, 'w'); + } + + return installationId; +} + +async function waitForInstallationId(configPath: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + const installationId = await readInstallationId(configPath); + if (installationId) return installationId; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return null; +} + +async function readInstallationId(configPath: string): Promise { + try { + const config = JSON.parse(await readFile(configPath, 'utf8')) as Partial; + return typeof config.installationId === 'string' && UUID_PATTERN.test(config.installationId) + ? config.installationId.toLowerCase() + : null; + } catch { + return null; + } +} + +function writeInstallationId(configPath: string, installationId: string, flag: 'w' | 'wx'): Promise { + return writeFile(configPath, `${JSON.stringify({ installationId }, null, 2)}\n`, { flag, mode: 0o600 }); +} + +function isFileExistsError(error: unknown): boolean { + return error instanceof Error && 'code' in error && (error as NodeJS.ErrnoException).code === 'EEXIST'; +} + +function getConfigDirectory(): string { + if (process.platform === 'darwin') { + return path.join(os.homedir(), 'Library', 'Application Support', 'mdl'); + } + + if (process.platform === 'win32') { + return path.join(process.env.LOCALAPPDATA ?? path.join(os.homedir(), 'AppData', 'Local'), 'mdl'); + } + + return path.join(process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), '.config'), 'mdl'); +} diff --git a/packages/cli/src/lib/manifest.test.ts b/packages/cli/src/lib/manifest.test.ts index 9af182f..7170c04 100644 --- a/packages/cli/src/lib/manifest.test.ts +++ b/packages/cli/src/lib/manifest.test.ts @@ -7,11 +7,7 @@ import { loadManifest, MANIFEST_FILE_NAME } from './manifest'; const temporaryDirectories: string[] = []; afterEach(async () => { - await Promise.all( - temporaryDirectories - .splice(0) - .map((directory) => rm(directory, { force: true, recursive: true })) - ); + await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true }))); }); describe('manifest', () => { @@ -26,10 +22,7 @@ describe('manifest', () => { test('loadManifest returns null when the manifest shape is invalid', async () => { const directory = await createTemporaryDirectory(); - await writeFile( - path.join(directory, MANIFEST_FILE_NAME), - `${JSON.stringify({ version: 1, tracks: 'bad' })}\n` - ); + await writeFile(path.join(directory, MANIFEST_FILE_NAME), `${JSON.stringify({ version: 1, tracks: 'bad' })}\n`); const manifest = await loadManifest(directory); diff --git a/packages/cli/src/lib/manifest.ts b/packages/cli/src/lib/manifest.ts index 0e37817..b5378f5 100644 --- a/packages/cli/src/lib/manifest.ts +++ b/packages/cli/src/lib/manifest.ts @@ -3,17 +3,11 @@ import path from 'node:path'; import { Either } from 'effect'; import { syncManifestSchema } from './schemas'; import type { SyncManifest } from './types'; -import { - decodeUnknownEither, - formatSchemaParseError, - ManifestDecodeError, -} from './utils'; +import { decodeUnknownEither, formatSchemaParseError, ManifestDecodeError } from './utils'; export const MANIFEST_FILE_NAME = '.mdl.json'; -export async function loadManifest( - directory: string -): Promise { +export async function loadManifest(directory: string): Promise { const manifestPath = path.join(directory, MANIFEST_FILE_NAME); try { @@ -30,18 +24,11 @@ export async function loadManifest( } } -export async function saveManifest( - directory: string, - manifest: SyncManifest -): Promise { +export async function saveManifest(directory: string, manifest: SyncManifest): Promise { await mkdir(directory, { recursive: true }); const manifestPath = path.join(directory, MANIFEST_FILE_NAME); - await writeFile( - manifestPath, - `${JSON.stringify(manifest, null, 2)}\n`, - 'utf8' - ); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, 'utf8'); return manifestPath; } diff --git a/packages/cli/src/lib/metadata.ts b/packages/cli/src/lib/metadata.ts index c36e7e3..d8b99d6 100644 --- a/packages/cli/src/lib/metadata.ts +++ b/packages/cli/src/lib/metadata.ts @@ -15,13 +15,9 @@ export async function writeTrackMetadata(params: { }): Promise { const { filePath, track, signal } = params; const ffmpegPath = await ensureFfmpegExecutable(); - const tempDir = await mkdtemp( - path.join(path.dirname(filePath), METADATA_TEMP_DIR_PREFIX) - ); + const tempDir = await mkdtemp(path.join(path.dirname(filePath), METADATA_TEMP_DIR_PREFIX)); const outputPath = path.join(tempDir, path.basename(filePath)); - let coverPath = track.artworkUrl - ? path.join(tempDir, getArtworkFileName(track.artworkUrl)) - : null; + let coverPath = track.artworkUrl ? path.join(tempDir, getArtworkFileName(track.artworkUrl)) : null; try { if (coverPath && track.artworkUrl) { @@ -59,16 +55,8 @@ export async function writeTrackMetadata(params: { } } -function buildMetadataArgs( - track: PlaylistTrack, - hasArtwork: boolean -): string[] { - const metadataArgs = [ - '-metadata', - `title=${track.title}`, - '-metadata', - `artist=${track.artists.join(', ')}`, - ]; +function buildMetadataArgs(track: PlaylistTrack, hasArtwork: boolean): string[] { + const metadataArgs = ['-metadata', `title=${track.title}`, '-metadata', `artist=${track.artists.join(', ')}`]; if (track.album) { metadataArgs.push('-metadata', `album=${track.album}`); @@ -88,11 +76,7 @@ function buildMetadataArgs( return metadataArgs; } -async function downloadArtwork( - artworkUrl: string, - destinationPath: string, - signal?: AbortSignal -): Promise { +async function downloadArtwork(artworkUrl: string, destinationPath: string, signal?: AbortSignal): Promise { const response = await fetch(artworkUrl, { signal }); if (!response.ok) { diff --git a/packages/cli/src/lib/network.test.ts b/packages/cli/src/lib/network.test.ts index aeb0203..802de9e 100644 --- a/packages/cli/src/lib/network.test.ts +++ b/packages/cli/src/lib/network.test.ts @@ -1,11 +1,8 @@ import { afterEach, describe, expect, mock, test } from 'bun:test'; -import { - configureNetwork, - getYouTubePoToken, - getYouTubeSessionOptions, -} from './network'; +import { configureNetwork, getYouTubePoToken, getYouTubeSessionOptions } from './network'; const defaultFetch = globalThis.fetch; +const installationId = '123e4567-e89b-42d3-a456-426614174000'; afterEach(() => { configureNetwork({}); @@ -15,10 +12,9 @@ afterEach(() => { describe('network', () => { test('fetches YouTube PO tokens from the default worker', async () => { - const fetchMock = mock(async (input: RequestInfo | URL) => { - expect(String(input)).toBe( - 'https://mdl-youtube-token.canari.workers.dev/token?videoId=abc123' - ); + const fetchMock = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + expect(String(input)).toBe('https://mdl-youtube-token.canari.workers.dev/token?videoId=abc123'); + expect(new Headers(init?.headers).get('x-mdl-installation-id')).toMatch(/^[0-9a-f-]{36}$/i); return new Response( JSON.stringify({ @@ -28,7 +24,7 @@ describe('network', () => { ); }); - configureNetwork({}); + configureNetwork({ installationId }); globalThis.fetch = fetchMock as unknown as typeof fetch; await expect(getYouTubePoToken('abc123')).resolves.toEqual({ @@ -47,12 +43,62 @@ describe('network', () => { }); test('rejects token responses without visitor data', async () => { + configureNetwork({ installationId }); globalThis.fetch = mock( async () => new Response(JSON.stringify({ poToken: 'po-token-123' })) ) as unknown as typeof fetch; - await expect(getYouTubePoToken('abc123')).rejects.toThrow( - 'YouTube token provider did not return visitor data.' + await expect(getYouTubePoToken('abc123')).rejects.toThrow('YouTube token provider did not return visitor data.'); + }); + + test('does not request a token when disabled', async () => { + const fetchMock = mock(async () => new Response()); + globalThis.fetch = fetchMock as unknown as typeof fetch; + configureNetwork({ installationId, usePoToken: false }); + + await expect(getYouTubePoToken('abc123')).resolves.toBeUndefined(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test('warns once and stops requesting tokens after a rate limit', async () => { + const warningHandler = mock(() => undefined); + const fetchMock = mock(async () => + Response.json( + { + error: { + message: 'You hit the daily limit for YouTube download tokens.', + }, + }, + { status: 429 } + ) ); + const { setPoTokenWarningHandler } = await import('./network'); + setPoTokenWarningHandler(warningHandler); + configureNetwork({ installationId }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await expect(getYouTubePoToken('abc123')).resolves.toBeUndefined(); + await expect(getYouTubePoToken('def456')).resolves.toBeUndefined(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(warningHandler).toHaveBeenCalledTimes(1); + expect(warningHandler).toHaveBeenCalledWith( + 'You hit the daily limit for YouTube download tokens. MDL will continue without one; some YouTube downloads may fail.' + ); + setPoTokenWarningHandler(null); + }); + + test('warns only once when parallel requests are rate limited', async () => { + const warningHandler = mock(() => undefined); + const fetchMock = mock(async () => Response.json({ error: { message: 'Daily limit reached.' } }, { status: 429 })); + const { setPoTokenWarningHandler } = await import('./network'); + setPoTokenWarningHandler(warningHandler); + configureNetwork({ installationId }); + globalThis.fetch = fetchMock as unknown as typeof fetch; + + await Promise.all([getYouTubePoToken('abc123'), getYouTubePoToken('def456'), getYouTubePoToken('ghi789')]); + + expect(warningHandler).toHaveBeenCalledTimes(1); + setPoTokenWarningHandler(null); }); }); diff --git a/packages/cli/src/lib/network.ts b/packages/cli/src/lib/network.ts index bfa9ffe..1e58558 100644 --- a/packages/cli/src/lib/network.ts +++ b/packages/cli/src/lib/network.ts @@ -1,8 +1,11 @@ import { ProxyAgent, fetch as undiciFetch } from 'undici'; import type { SessionOptions } from 'youtubei.js'; +import { getInstallationId } from './installation'; export type NetworkOptions = { + installationId?: string; proxy?: string; + usePoToken?: boolean; ytCookie?: string; ytUserAgent?: string; }; @@ -15,14 +18,18 @@ export type YouTubePoToken = { const defaultFetch = globalThis.fetch.bind(globalThis); const YOUTUBE_PO_TOKEN_PROVIDER_URL = - process.env.MDL_YOUTUBE_TOKEN_PROVIDER_URL ?? - 'https://mdl-youtube-token.canari.workers.dev/token'; + process.env.MDL_YOUTUBE_TOKEN_PROVIDER_URL ?? 'https://mdl-youtube-token.canari.workers.dev/token'; let currentNetworkOptions: NetworkOptions = {}; let proxyAgent: ProxyAgent | null = null; +let poTokenWarningHandler: ((message: string) => void) | null = null; +let isPoTokenRateLimited = false; +let hasWarnedPoTokenRateLimit = false; export function configureNetwork(options: NetworkOptions): void { currentNetworkOptions = { ...options }; + isPoTokenRateLimited = false; + hasWarnedPoTokenRateLimit = false; if (proxyAgent) { proxyAgent.close(); @@ -38,9 +45,11 @@ export function configureNetwork(options: NetworkOptions): void { globalThis.fetch = createProxyFetch(proxyAgent); } -export function getYouTubeSessionOptions( - poToken?: YouTubePoToken -): Partial { +export function setPoTokenWarningHandler(handler: ((message: string) => void) | null): void { + poTokenWarningHandler = handler; +} + +export function getYouTubeSessionOptions(poToken?: YouTubePoToken): Partial { return { cookie: currentNetworkOptions.ytCookie, user_agent: currentNetworkOptions.ytUserAgent, @@ -59,28 +68,51 @@ export function getYouTubeSessionCacheKey(poToken?: YouTubePoToken): string { }); } -export async function getYouTubePoToken( - videoId: string, - signal?: AbortSignal -): Promise { +export async function getYouTubePoToken(videoId: string, signal?: AbortSignal): Promise { + if (currentNetworkOptions.usePoToken === false || isPoTokenRateLimited) { + return undefined; + } + const url = new URL(YOUTUBE_PO_TOKEN_PROVIDER_URL); url.searchParams.set('videoId', videoId); - const response = await fetch(url, { signal }); + const installationId = currentNetworkOptions.installationId ?? (await getInstallationId()); + const response = await fetch(url, { + signal, + headers: { 'x-mdl-installation-id': installationId }, + }); if (!response.ok) { - throw new Error( - `YouTube token provider failed (${response.status}): ${await response.text()}` - ); + if (response.status === 429) { + const message = await getRateLimitMessage(response); + isPoTokenRateLimited = true; + if (!hasWarnedPoTokenRateLimit) { + hasWarnedPoTokenRateLimit = true; + poTokenWarningHandler?.(`${message} MDL will continue without one; some YouTube downloads may fail.`); + } + return undefined; + } + + throw new Error(`YouTube token provider failed (${response.status}): ${await response.text()}`); } return parseYouTubePoToken(await response.json()); } +async function getRateLimitMessage(response: Response): Promise { + const fallback = 'You hit the limit for YouTube download tokens.'; + + try { + const payload = (await response.json()) as { + error?: { message?: unknown }; + }; + return typeof payload.error?.message === 'string' ? payload.error.message : fallback; + } catch { + return fallback; + } +} + function createProxyFetch(agent: ProxyAgent): typeof fetch { - return (async ( - input: Parameters[0], - init?: Parameters[1] - ) => + return (async (input: Parameters[0], init?: Parameters[1]) => undiciFetch( input as never, { @@ -97,25 +129,17 @@ function parseYouTubePoToken(value: unknown): YouTubePoToken { const payload = value as Partial; - if ( - typeof payload.poToken !== 'string' || - payload.poToken.trim().length === 0 - ) { + if (typeof payload.poToken !== 'string' || payload.poToken.trim().length === 0) { throw new Error('YouTube token provider did not return a PO token.'); } - if ( - typeof payload.visitorData !== 'string' || - payload.visitorData.trim().length === 0 - ) { + if (typeof payload.visitorData !== 'string' || payload.visitorData.trim().length === 0) { throw new Error('YouTube token provider did not return visitor data.'); } return { poToken: payload.poToken, visitorData: payload.visitorData, - ...(typeof payload.expiresAt === 'string' - ? { expiresAt: payload.expiresAt } - : {}), + ...(typeof payload.expiresAt === 'string' ? { expiresAt: payload.expiresAt } : {}), }; } diff --git a/packages/cli/src/lib/providers/AmazonMusic.test.ts b/packages/cli/src/lib/providers/AmazonMusic.test.ts index ea97901..34f3ce9 100644 --- a/packages/cli/src/lib/providers/AmazonMusic.test.ts +++ b/packages/cli/src/lib/providers/AmazonMusic.test.ts @@ -64,8 +64,7 @@ describe('amazon-music', () => { artistUrl: 'https://music.amazon.com/artists/B01HG4E4LI', artworkUrl: 'https://m.media-amazon.com/images/I/track.jpg', durationMs: 126000, - sourceUrl: - 'https://music.amazon.com/albums/B09DS56JYZ?trackAsin=B09DS24Z6F', + sourceUrl: 'https://music.amazon.com/albums/B09DS56JYZ?trackAsin=B09DS24Z6F', title: 'Antarctica [Explicit]', }); }); diff --git a/packages/cli/src/lib/providers/AmazonMusic.ts b/packages/cli/src/lib/providers/AmazonMusic.ts index 527a493..02b0140 100644 --- a/packages/cli/src/lib/providers/AmazonMusic.ts +++ b/packages/cli/src/lib/providers/AmazonMusic.ts @@ -2,8 +2,7 @@ import type { PlaylistMetadata, PlaylistTrack } from '../types'; import { getFirstNonEmptyString } from '../utils'; import type { FetchOptions, ProviderOptions } from './Providers'; -const AMAZON_BOT_USER_AGENT = - 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'; +const AMAZON_BOT_USER_AGENT = 'facebookexternalhit/1.1 (+http://www.facebook.com/externalhit_uatext.php)'; export class AmazonMusicProvider implements ProviderOptions { public readonly provider = 'amazon-music'; @@ -25,10 +24,7 @@ export class AmazonMusicProvider implements ProviderOptions { ); } - public async fetch( - url: string, - options: FetchOptions - ): Promise { + public async fetch(url: string, options: FetchOptions): Promise { const { signal } = options; const trackAsin = this.extractTrackAsin(url); @@ -40,62 +36,40 @@ export class AmazonMusicProvider implements ProviderOptions { const html = await this.fetchHtml(url, signal); const playlist = this.parsePlaylistHtml(html); - const tracks = await this.mapWithConcurrency( - playlist.trackAsins, - 4, - async (asin) => { - const trackHtml = await this.fetchHtml( - `${url}?do=play&trackAsin=${asin}`, - signal - ); - const trackPage = this.parseTrackHtml(trackHtml); - const artist = trackPage.artistUrl - ? await this.getCachedName( - this.artistNameCache, - trackPage.artistUrl, - signal - ) - : undefined; - const albumTitle = trackPage.albumUrl - ? await this.getCachedName( - this.albumTitleCache, - trackPage.albumUrl, - signal - ) - : undefined; - - if (!trackPage.title || !artist) { - return null; - } - - return { - id: asin, - title: trackPage.title, - artists: [artist], - album: albumTitle, - artworkUrl: getFirstNonEmptyString( - trackPage.artworkUrl, - playlist.artworkUrl - ), - durationMs: trackPage.durationMs, - sourceUrl: trackPage.sourceUrl, - }; + const tracks = await this.mapWithConcurrency(playlist.trackAsins, 4, async (asin) => { + const trackHtml = await this.fetchHtml(`${url}?do=play&trackAsin=${asin}`, signal); + const trackPage = this.parseTrackHtml(trackHtml); + const artist = trackPage.artistUrl + ? await this.getCachedName(this.artistNameCache, trackPage.artistUrl, signal) + : undefined; + const albumTitle = trackPage.albumUrl + ? await this.getCachedName(this.albumTitleCache, trackPage.albumUrl, signal) + : undefined; + + if (!trackPage.title || !artist) { + return null; } - ); + + return { + id: asin, + title: trackPage.title, + artists: [artist], + album: albumTitle, + artworkUrl: getFirstNonEmptyString(trackPage.artworkUrl, playlist.artworkUrl), + durationMs: trackPage.durationMs, + sourceUrl: trackPage.sourceUrl, + }; + }); const normalizedTracks = tracks.filter((track) => track !== null); if (normalizedTracks.length === 0) { - throw new Error( - `No tracks were found in the Amazon Music ${collectionKind}.` - ); + throw new Error(`No tracks were found in the Amazon Music ${collectionKind}.`); } return { id: this.extractPlaylistId(url), - title: - playlist.title || - `Amazon Music ${collectionKind === 'album' ? 'Album' : 'Playlist'}`, + title: playlist.title || `Amazon Music ${collectionKind === 'album' ? 'Album' : 'Playlist'}`, owner: playlist.owner, artworkUrl: playlist.artworkUrl, provider: 'amazon-music', @@ -104,35 +78,18 @@ export class AmazonMusicProvider implements ProviderOptions { }; } - private async fetchTrack( - sourceUrl: string, - trackAsin: string, - signal?: AbortSignal - ): Promise { - const trackHtml = await this.fetchHtml( - this.buildTrackPlaybackUrl(sourceUrl, trackAsin), - signal - ); + private async fetchTrack(sourceUrl: string, trackAsin: string, signal?: AbortSignal): Promise { + const trackHtml = await this.fetchHtml(this.buildTrackPlaybackUrl(sourceUrl, trackAsin), signal); const trackPage = this.parseTrackHtml(trackHtml); const artist = trackPage.artistUrl - ? await this.getCachedName( - this.artistNameCache, - trackPage.artistUrl, - signal - ) + ? await this.getCachedName(this.artistNameCache, trackPage.artistUrl, signal) : undefined; const albumTitle = trackPage.albumUrl - ? await this.getCachedName( - this.albumTitleCache, - trackPage.albumUrl, - signal - ) + ? await this.getCachedName(this.albumTitleCache, trackPage.albumUrl, signal) : undefined; if (!trackPage.title || !artist) { - throw new Error( - 'Could not find Amazon Music track metadata in the page.' - ); + throw new Error('Could not find Amazon Music track metadata in the page.'); } const track: PlaylistTrack = { @@ -166,9 +123,7 @@ export class AmazonMusicProvider implements ProviderOptions { ); if (!title || trackAsins.length === 0) { - throw new Error( - 'Could not find Amazon Music collection metadata in the page.' - ); + throw new Error('Could not find Amazon Music collection metadata in the page.'); } return { @@ -180,23 +135,14 @@ export class AmazonMusicProvider implements ProviderOptions { } public parseTrackHtml(html: string): AmazonTrackPageData { - const sourceUrl = this.decodeHtmlAttribute( - this.extractMetaProperty(html, 'al:web:url') || '' - ); + const sourceUrl = this.decodeHtmlAttribute(this.extractMetaProperty(html, 'al:web:url') || ''); const duration = this.extractMetaProperty(html, 'music:duration'); return { - albumUrl: this.decodeHtmlAttribute( - this.extractMetaProperty(html, 'music:album') || '' - ), - artistUrl: this.decodeHtmlAttribute( - this.extractMetaProperty(html, 'music:musician') || '' - ), + albumUrl: this.decodeHtmlAttribute(this.extractMetaProperty(html, 'music:album') || ''), + artistUrl: this.decodeHtmlAttribute(this.extractMetaProperty(html, 'music:musician') || ''), artworkUrl: this.extractMetaProperty(html, 'og:image'), - durationMs: - duration && Number.isFinite(Number(duration)) - ? Math.round(Number(duration) * 1000) - : undefined, + durationMs: duration && Number.isFinite(Number(duration)) ? Math.round(Number(duration) * 1000) : undefined, sourceUrl: sourceUrl || undefined, title: this.extractMetaProperty(html, 'og:title'), }; @@ -214,9 +160,7 @@ export class AmazonMusicProvider implements ProviderOptions { } const html = await this.fetchHtml(normalizedUrl, signal); - const name = this.extractMetaProperty(html, 'og:title') - ?.split(' – ')[0] - ?.trim(); + const name = this.extractMetaProperty(html, 'og:title')?.split(' – ')[0]?.trim(); if (name) { cache.set(normalizedUrl, name); @@ -234,9 +178,7 @@ export class AmazonMusicProvider implements ProviderOptions { }); if (!response.ok) { - throw new Error( - `Amazon Music request failed with status ${response.status}.` - ); + throw new Error(`Amazon Music request failed with status ${response.status}.`); } return response.text(); @@ -244,14 +186,10 @@ export class AmazonMusicProvider implements ProviderOptions { private extractPlaylistId(sourceUrl: string): string { const parsedUrl = new URL(sourceUrl); - const match = parsedUrl.pathname.match( - /\/(?:albums|user-playlists|playlists)\/([A-Za-z0-9]+)/i - ); + const match = parsedUrl.pathname.match(/\/(?:albums|user-playlists|playlists)\/([A-Za-z0-9]+)/i); if (!match?.[1]) { - throw new Error( - 'Could not determine the Amazon Music collection id from the URL.' - ); + throw new Error('Could not determine the Amazon Music collection id from the URL.'); } return match[1]; @@ -272,22 +210,12 @@ export class AmazonMusicProvider implements ProviderOptions { } private getCollectionKind(sourceUrl: string): 'album' | 'playlist' { - return new URL(sourceUrl).pathname.includes('/albums/') - ? 'album' - : 'playlist'; + return new URL(sourceUrl).pathname.includes('/albums/') ? 'album' : 'playlist'; } - private extractMetaProperty( - html: string, - property: string - ): string | undefined { + private extractMetaProperty(html: string, property: string): string | undefined { const escapedProperty = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const match = html.match( - new RegExp( - `') - .replace(/&#x([0-9a-f]+);/gi, (_, hex) => - String.fromCodePoint(Number.parseInt(hex, 16)) - ) - .replace(/&#(\d+);/g, (_, decimal) => - String.fromCodePoint(Number.parseInt(decimal, 10)) - ); + .replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16))) + .replace(/&#(\d+);/g, (_, decimal) => String.fromCodePoint(Number.parseInt(decimal, 10))); } private async mapWithConcurrency( @@ -315,19 +239,13 @@ export class AmazonMusicProvider implements ProviderOptions { const results = new Array(values.length); let cursor = 0; - const workers = Array.from( - { length: Math.min(concurrency, values.length) }, - async () => { - while (cursor < values.length) { - const currentIndex = cursor; - cursor += 1; - results[currentIndex] = await mapper( - values[currentIndex], - currentIndex - ); - } + const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => { + while (cursor < values.length) { + const currentIndex = cursor; + cursor += 1; + results[currentIndex] = await mapper(values[currentIndex], currentIndex); } - ); + }); await Promise.all(workers); return results; diff --git a/packages/cli/src/lib/providers/AppleMusic.test.ts b/packages/cli/src/lib/providers/AppleMusic.test.ts index 972af96..0c38189 100644 --- a/packages/cli/src/lib/providers/AppleMusic.test.ts +++ b/packages/cli/src/lib/providers/AppleMusic.test.ts @@ -23,20 +23,16 @@ describe('apple-music', () => { expect(playlist.id).toBe('pl.test'); expect(playlist.title).toBe('New Music Daily'); expect(playlist.owner).toBe('Apple Music'); - expect(playlist.artworkUrl).toBe( - 'https://is1-ssl.mzstatic.com/image/thumb/Features/v4/test/1200x1200SC.DN01.jpg' - ); + expect(playlist.artworkUrl).toBe('https://is1-ssl.mzstatic.com/image/thumb/Features/v4/test/1200x1200SC.DN01.jpg'); expect(playlist.tracks).toHaveLength(2); expect(playlist.tracks[0]).toEqual({ id: '1868862384', title: 'SWIM', artists: ['BTS'], album: 'ARIRANG', - artworkUrl: - 'https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/test/1200x1200bb.jpg', + artworkUrl: 'https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/test/1200x1200bb.jpg', durationMs: 159008, - sourceUrl: - 'https://music.apple.com/us/album/swim/1868862375?i=1868862384', + sourceUrl: 'https://music.apple.com/us/album/swim/1868862375?i=1868862384', }); }); @@ -63,11 +59,9 @@ describe('apple-music', () => { title: 'Never Gonna Give You Up', artists: ['Rick Astley'], album: 'Whenever You Need Somebody', - artworkUrl: - 'https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/track/1200x1200bb.jpg', + artworkUrl: 'https://is1-ssl.mzstatic.com/image/thumb/Music211/v4/track/1200x1200bb.jpg', durationMs: 213000, - sourceUrl: - 'https://music.apple.com/us/album/never-gonna-give-you-up/1440837083?i=1440837084', + sourceUrl: 'https://music.apple.com/us/album/never-gonna-give-you-up/1440837083?i=1440837084', }); }); @@ -81,10 +75,7 @@ describe('apple-music', () => { `; - const playlist = provider.parsePlaylistHtml( - html, - 'https://music.apple.com/us/playlist/new-music-daily/pl.test' - ); + const playlist = provider.parsePlaylistHtml(html, 'https://music.apple.com/us/playlist/new-music-daily/pl.test'); expect(playlist.tracks[0]?.artworkUrl).toBe( 'https://is1-ssl.mzstatic.com/image/thumb/Features/v4/test/1200x1200SC.DN01.jpg' diff --git a/packages/cli/src/lib/providers/AppleMusic.ts b/packages/cli/src/lib/providers/AppleMusic.ts index 370adba..ff4f081 100644 --- a/packages/cli/src/lib/providers/AppleMusic.ts +++ b/packages/cli/src/lib/providers/AppleMusic.ts @@ -11,9 +11,7 @@ export class AppleMusicProvider implements ProviderOptions { const pathname = url.pathname.replace(/\/+$/, '') || '/'; return ( - ['music.apple.com', 'geo.music.apple.com'].includes( - url.hostname.toLowerCase() - ) && + ['music.apple.com', 'geo.music.apple.com'].includes(url.hostname.toLowerCase()) && [ /^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?album\/[^/]+\/\d+(?:\/)?$/i, /^\/(?:[a-z]{2}(?:-[a-z]{2})?\/)?playlist\/[^/]+\/pl\.[A-Za-z0-9.]+(?:\/)?$/i, @@ -22,10 +20,7 @@ export class AppleMusicProvider implements ProviderOptions { ); } - public async fetch( - url: string, - _options: FetchOptions - ): Promise { + public async fetch(url: string, _options: FetchOptions): Promise { const trackId = this.extractTrackId(url); const response = await fetch(url, { headers: { @@ -34,39 +29,21 @@ export class AppleMusicProvider implements ProviderOptions { }); if (!response.ok) { - throw new Error( - `Apple Music request failed with status ${response.status}.` - ); + throw new Error(`Apple Music request failed with status ${response.status}.`); } - return this.parsePlaylistHtml( - await response.text(), - url, - trackId ?? undefined - ); + return this.parsePlaylistHtml(await response.text(), url, trackId ?? undefined); } - public parsePlaylistHtml( - html: string, - sourceUrl: string, - trackId?: string - ): PlaylistMetadata { + public parsePlaylistHtml(html: string, sourceUrl: string, trackId?: string): PlaylistMetadata { const serializedData = this.extractSerializedServerData(html); const playlistHeader = this.findPlaylistHeader(serializedData); - const collectionArtworkUrl = this.normalizeArtworkUrl( - playlistHeader?.artwork - ); - const tracks = this.findTrackItems(serializedData).map((track) => - this.normalizeTrack(track, collectionArtworkUrl) - ); - const selectedTrack = trackId - ? tracks.find((track) => track.id === trackId) - : undefined; + const collectionArtworkUrl = this.normalizeArtworkUrl(playlistHeader?.artwork); + const tracks = this.findTrackItems(serializedData).map((track) => this.normalizeTrack(track, collectionArtworkUrl)); + const selectedTrack = trackId ? tracks.find((track) => track.id === trackId) : undefined; if (!playlistHeader) { - throw new Error( - 'Could not find Apple Music collection metadata in the page.' - ); + throw new Error('Could not find Apple Music collection metadata in the page.'); } if (tracks.length === 0 || (trackId && !selectedTrack)) { @@ -88,11 +65,8 @@ export class AppleMusicProvider implements ProviderOptions { const isAlbum = playlistHeader.contentDescriptor?.kind === 'album'; return { id: - playlistHeader.contentDescriptor?.identifiers?.storeAdamID ?? - `${isAlbum ? 'album' : 'playlist'}-${Date.now()}`, - title: - playlistHeader.title?.trim() || - `Apple Music ${isAlbum ? 'Album' : 'Playlist'}`, + playlistHeader.contentDescriptor?.identifiers?.storeAdamID ?? `${isAlbum ? 'album' : 'playlist'}-${Date.now()}`, + title: playlistHeader.title?.trim() || `Apple Music ${isAlbum ? 'Album' : 'Playlist'}`, owner: playlistHeader.subtitleLinks?.[0]?.title?.trim() || undefined, artworkUrl: collectionArtworkUrl, provider: 'apple-music', @@ -114,37 +88,26 @@ export class AppleMusicProvider implements ProviderOptions { } private extractSerializedServerData(html: string): AppleMusicSerializedData { - const match = html.match( - /