diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..a188e069 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/* diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 00000000..ccd3327e --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,57 @@ +name: Deploy VitePress site to Pages + +on: + push: + paths: + - "docs/**" + - ".github/workflows/deploy-docs.yml" + + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: pages + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Install dependencies + working-directory: docs + run: bun install + + - name: Build with VitePress + working-directory: docs + run: bun run docs:build + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: docs/.vitepress/dist + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + needs: build + runs-on: ubuntu-latest + name: Deploy + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 5f79cc2e..56941648 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,9 @@ extra/* config.toml local.config.toml .env + +# VitePress Docs +docs/node_modules/ +docs/.vitepress/dist/ +docs/.vitepress/cache/ + diff --git a/config.example.toml b/config.example.toml index ad4a17e0..6e0beffb 100644 --- a/config.example.toml +++ b/config.example.toml @@ -19,6 +19,32 @@ tape = { tape_stop = true, tape_stop_duration_ms = 500, curve = "sinusoidal" } # # List of mirror provider patterns. %ISRC% or %QUERY% providers = ["ytsearch:%ISRC%", "ytsearch:%QUERY%", "scsearch:%QUERY%"] +[player.mirrors.best_match] +# Enable weighted scoring to find the best-matching candidate. +# When false, the first resolvable result from each provider is accepted immediately. +# Default: true +scoring = true + +# Prefixes treated as throttled (rate-sensitive) — tried sequentially after all parallel free providers. +# Default: ["ytmsearch:", "ytsearch:"] +throttled_prefixes = ["ytmsearch:", "ytsearch:"] + +# Confidence thresholds (only used when scoring = true). All values in [0.0, 1.0]. +# immediate_use — accept instantly, cancel remaining parallel searches. +# high_confidence — try up to 2 candidates instead of 3. +# min_similarity — discard anything below this score entirely. +immediate_use = 0.88 +high_confidence = 0.75 +min_similarity = 0.50 + +# Scoring weights (should sum to 1.0). +weight_title = 0.50 +weight_artist = 0.30 +weight_duration = 0.20 + +# Milliseconds within which two track durations are considered identical. +duration_tolerance_ms = 3000 + [logging] level = "info" # trace | debug | info | warn | error | off filters = "rustalink=debug" # e.g. "rustalink=debug,davey=off" diff --git a/docs/.gitignore b/docs/.gitignore new file mode 100644 index 00000000..c9daea57 --- /dev/null +++ b/docs/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +.vitepress/dist +.vitepress/cache diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js new file mode 100644 index 00000000..5312a884 --- /dev/null +++ b/docs/.vitepress/config.js @@ -0,0 +1,35 @@ +import { defineConfig } from 'vitepress' + +export default defineConfig({ + title: "Rustalink", + description: "High-performance Rust audio server documentation", + base: '/Rustalink/', + cleanUrls: true, + themeConfig: { + logo: '/logo.svg', + nav: [ + { text: 'Docs', link: '/' } + ], + sidebar: [ + { + text: 'Guide', + items: [ + { text: 'Introduction', link: '/' }, + { text: 'Installation', link: '/guide/installation' }, + { text: 'Docker', link: '/guide/docker' }, + { text: 'Configuration', link: '/guide/configuration' }, + { text: 'Architecture', link: '/guide/architecture' }, + { text: 'Filters', link: '/guide/filters' }, + { text: 'REST API', link: '/guide/api' }, + { text: 'Pterodactyl', link: '/guide/pterodactyl' } + ] + } + ], + socialLinks: [ + { icon: 'github', link: 'https://github.com/bongodevs/Rustalink' } + ], + search: { + provider: 'local' + } + } +}) diff --git a/docs/.vitepress/theme/index.js b/docs/.vitepress/theme/index.js new file mode 100644 index 00000000..b408de0b --- /dev/null +++ b/docs/.vitepress/theme/index.js @@ -0,0 +1,9 @@ +import DefaultTheme from 'vitepress/theme' +import './style.css' + +export default { + extends: DefaultTheme, + enhanceApp({ app, router, siteData }) { + // Custom enhancements can go here + } +} diff --git a/docs/.vitepress/theme/style.css b/docs/.vitepress/theme/style.css new file mode 100644 index 00000000..52f0bdbe --- /dev/null +++ b/docs/.vitepress/theme/style.css @@ -0,0 +1,31 @@ +:root { + --vp-c-brand-1: #FF6A00; + --vp-c-brand-2: #D84315; + --vp-c-brand-3: #FF8A33; + --vp-home-hero-name-color: transparent; + --vp-home-hero-name-background: -webkit-linear-gradient(120deg, #FF6A00 30%, #D84315); +} + +.dark { + --vp-c-bg: #0f0f0f; + --vp-c-bg-alt: #1a1a1a; + --vp-c-bg-elv: #1a1a1a; + --vp-c-bg-soft: #1a1a1a; + + --vp-c-text-1: #ffffff; + --vp-c-text-2: rgba(255, 255, 255, 0.7); + + --vp-button-brand-bg: #FF6A00; + --vp-button-brand-hover-bg: #D84315; + --vp-button-brand-active-bg: #D84315; +} + +html { + color-scheme: dark !important; +} + +/* Force dark mode */ +body { + background-color: var(--vp-c-bg); + color: var(--vp-c-text-1); +} diff --git a/docs/bun.lock b/docs/bun.lock new file mode 100644 index 00000000..d3c3aba4 --- /dev/null +++ b/docs/bun.lock @@ -0,0 +1,360 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "rustalink-docs", + "dependencies": { + "vitepress": "^1.6.4", + "vue": "^3.5.30", + }, + }, + }, + "packages": { + "@algolia/abtesting": ["@algolia/abtesting@1.15.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-2yuIC48rUuHGhU1U5qJ9kJHaxYpJ0jpDHJVI5ekOxSMYXlH4+HP+pA31G820lsAznfmu2nzDV7n5RO44zIY1zw=="], + + "@algolia/autocomplete-core": ["@algolia/autocomplete-core@1.17.7", "", { "dependencies": { "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", "@algolia/autocomplete-shared": "1.17.7" } }, "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q=="], + + "@algolia/autocomplete-plugin-algolia-insights": ["@algolia/autocomplete-plugin-algolia-insights@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "search-insights": ">= 1 < 3" } }, "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A=="], + + "@algolia/autocomplete-preset-algolia": ["@algolia/autocomplete-preset-algolia@1.17.7", "", { "dependencies": { "@algolia/autocomplete-shared": "1.17.7" }, "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA=="], + + "@algolia/autocomplete-shared": ["@algolia/autocomplete-shared@1.17.7", "", { "peerDependencies": { "@algolia/client-search": ">= 4.9.1 < 6", "algoliasearch": ">= 4.9.1 < 6" } }, "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg=="], + + "@algolia/client-abtesting": ["@algolia/client-abtesting@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-h6M7HzPin+45/l09q0r2dYmocSSt2MMGOOk5c4O5K/bBBlEwf1BKfN6z+iX4b8WXcQQhf7rgQwC52kBZJt/ZZw=="], + + "@algolia/client-analytics": ["@algolia/client-analytics@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-048T9/Z8OeLmTk8h76QUqaNFp7Rq2VgS2Zm6Y2tNMYGQ1uNuzePY/udB5l5krlXll7ZGflyCjFvRiOtlPZpE9g=="], + + "@algolia/client-common": ["@algolia/client-common@5.49.1", "", {}, "sha512-vp5/a9ikqvf3mn9QvHN8PRekn8hW34aV9eX+O0J5mKPZXeA6Pd5OQEh2ZWf7gJY6yyfTlLp5LMFzQUAU+Fpqpg=="], + + "@algolia/client-insights": ["@algolia/client-insights@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-B6N7PgkvYrul3bntTz/l6uXnhQ2bvP+M7NqTcayh681tSqPaA5cJCUBp/vrP7vpPRpej4Eeyx2qz5p0tE/2N2g=="], + + "@algolia/client-personalization": ["@algolia/client-personalization@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-v+4DN+lkYfBd01Hbnb9ZrCHe7l+mvihyx218INRX/kaCXROIWUDIT1cs3urQxfE7kXBFnLsqYeOflQALv/gA5w=="], + + "@algolia/client-query-suggestions": ["@algolia/client-query-suggestions@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-Un11cab6ZCv0W+Jiak8UktGIqoa4+gSNgEZNfG8m8eTsXGqwIEr370H3Rqwj87zeNSlFpH2BslMXJ/cLNS1qtg=="], + + "@algolia/client-search": ["@algolia/client-search@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-Nt9hri7nbOo0RipAsGjIssHkpLMHHN/P7QqENywAq5TLsoYDzUyJGny8FEiD/9KJUxtGH8blGpMedilI6kK3rA=="], + + "@algolia/ingestion": ["@algolia/ingestion@1.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-b5hUXwDqje0Y4CpU6VL481DXgPgxpTD5sYMnfQTHKgUispGnaCLCm2/T9WbJo1YNUbX3iHtYDArp804eD6CmRQ=="], + + "@algolia/monitoring": ["@algolia/monitoring@1.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-bvrXwZ0WsL3rN6Q4m4QqxsXFCo6WAew7sAdrpMQMK4Efn4/W920r9ptOuckejOSSvyLr9pAWgC5rsHhR2FYuYw=="], + + "@algolia/recommend": ["@algolia/recommend@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-h2yz3AGeGkQwNgbLmoe3bxYs8fac4An1CprKTypYyTU/k3Q+9FbIvJ8aS1DoBKaTjSRZVoyQS7SZQio6GaHbZw=="], + + "@algolia/requester-browser-xhr": ["@algolia/requester-browser-xhr@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1" } }, "sha512-2UPyRuUR/qpqSqH8mxFV5uBZWEpxhGPHLlx9Xf6OVxr79XO2ctzZQAhsmTZ6X22x+N8MBWpB9UEky7YU2HGFgA=="], + + "@algolia/requester-fetch": ["@algolia/requester-fetch@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1" } }, "sha512-N+xlE4lN+wpuT+4vhNEwPVlrfN+DWAZmSX9SYhbz986Oq8AMsqdntOqUyiOXVxYsQtfLwmiej24vbvJGYv1Qtw=="], + + "@algolia/requester-node-http": ["@algolia/requester-node-http@5.49.1", "", { "dependencies": { "@algolia/client-common": "5.49.1" } }, "sha512-zA5bkUOB5PPtTr182DJmajCiizHp0rCJQ0Chf96zNFvkdESKYlDeYA3tQ7r2oyHbu/8DiohAQ5PZ85edctzbXA=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + + "@docsearch/css": ["@docsearch/css@3.8.2", "", {}, "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ=="], + + "@docsearch/js": ["@docsearch/js@3.8.2", "", { "dependencies": { "@docsearch/react": "3.8.2", "preact": "^10.0.0" } }, "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ=="], + + "@docsearch/react": ["@docsearch/react@3.8.2", "", { "dependencies": { "@algolia/autocomplete-core": "1.17.7", "@algolia/autocomplete-preset-algolia": "1.17.7", "@docsearch/css": "3.8.2", "algoliasearch": "^5.14.2" }, "peerDependencies": { "@types/react": ">= 16.8.0 < 19.0.0", "react": ">= 16.8.0 < 19.0.0", "react-dom": ">= 16.8.0 < 19.0.0", "search-insights": ">= 1 < 3" }, "optionalPeers": ["@types/react", "react", "react-dom", "search-insights"] }, "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@iconify-json/simple-icons": ["@iconify-json/simple-icons@1.2.72", "", { "dependencies": { "@iconify/types": "*" } }, "sha512-wkcixntHvaCoqPqerGrNFcHQ3Yx1ux4ZkhscCDK0DEHpP62XCH+cxq1HTsRjbUiQl/M9K8bj03HF6Wgn5iE2rQ=="], + + "@iconify/types": ["@iconify/types@2.0.0", "", {}, "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.59.0", "", { "os": "android", "cpu": "arm" }, "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.59.0", "", { "os": "android", "cpu": "arm64" }, "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.59.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.59.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.59.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.59.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.59.0", "", { "os": "linux", "cpu": "arm" }, "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.59.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.59.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.59.0", "", { "os": "linux", "cpu": "none" }, "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.59.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.59.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.59.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.59.0", "", { "os": "none", "cpu": "arm64" }, "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.59.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.59.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA=="], + + "@shikijs/core": ["@shikijs/core@2.5.0", "", { "dependencies": { "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.4" } }, "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg=="], + + "@shikijs/engine-javascript": ["@shikijs/engine-javascript@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^3.1.0" } }, "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w=="], + + "@shikijs/engine-oniguruma": ["@shikijs/engine-oniguruma@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2" } }, "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw=="], + + "@shikijs/langs": ["@shikijs/langs@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w=="], + + "@shikijs/themes": ["@shikijs/themes@2.5.0", "", { "dependencies": { "@shikijs/types": "2.5.0" } }, "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw=="], + + "@shikijs/transformers": ["@shikijs/transformers@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/types": "2.5.0" } }, "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg=="], + + "@shikijs/types": ["@shikijs/types@2.5.0", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw=="], + + "@shikijs/vscode-textmate": ["@shikijs/vscode-textmate@10.0.2", "", {}, "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], + + "@types/linkify-it": ["@types/linkify-it@5.0.0", "", {}, "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="], + + "@types/markdown-it": ["@types/markdown-it@14.1.2", "", { "dependencies": { "@types/linkify-it": "^5", "@types/mdurl": "^2" } }, "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog=="], + + "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], + + "@types/mdurl": ["@types/mdurl@2.0.0", "", {}, "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="], + + "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + + "@types/web-bluetooth": ["@types/web-bluetooth@0.0.21", "", {}, "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA=="], + + "@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/shared": "3.5.30", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.30", "", { "dependencies": { "@vue/compiler-core": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.30", "", { "dependencies": { "@babel/parser": "^7.29.0", "@vue/compiler-core": "3.5.30", "@vue/compiler-dom": "3.5.30", "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.8", "source-map-js": "^1.2.1" } }, "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA=="], + + "@vue/devtools-api": ["@vue/devtools-api@7.7.9", "", { "dependencies": { "@vue/devtools-kit": "^7.7.9" } }, "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g=="], + + "@vue/devtools-kit": ["@vue/devtools-kit@7.7.9", "", { "dependencies": { "@vue/devtools-shared": "^7.7.9", "birpc": "^2.3.0", "hookable": "^5.5.3", "mitt": "^3.0.1", "perfect-debounce": "^1.0.0", "speakingurl": "^14.0.1", "superjson": "^2.2.2" } }, "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA=="], + + "@vue/devtools-shared": ["@vue/devtools-shared@7.7.9", "", { "dependencies": { "rfdc": "^1.4.1" } }, "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.30", "", { "dependencies": { "@vue/shared": "3.5.30" } }, "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/shared": "3.5.30" } }, "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.30", "", { "dependencies": { "@vue/reactivity": "3.5.30", "@vue/runtime-core": "3.5.30", "@vue/shared": "3.5.30", "csstype": "^3.2.3" } }, "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.30", "", { "dependencies": { "@vue/compiler-ssr": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "vue": "3.5.30" } }, "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ=="], + + "@vue/shared": ["@vue/shared@3.5.30", "", {}, "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ=="], + + "@vueuse/core": ["@vueuse/core@12.8.2", "", { "dependencies": { "@types/web-bluetooth": "^0.0.21", "@vueuse/metadata": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" } }, "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ=="], + + "@vueuse/integrations": ["@vueuse/integrations@12.8.2", "", { "dependencies": { "@vueuse/core": "12.8.2", "@vueuse/shared": "12.8.2", "vue": "^3.5.13" }, "peerDependencies": { "async-validator": "^4", "axios": "^1", "change-case": "^5", "drauu": "^0.4", "focus-trap": "^7", "fuse.js": "^7", "idb-keyval": "^6", "jwt-decode": "^4", "nprogress": "^0.2", "qrcode": "^1.5", "sortablejs": "^1", "universal-cookie": "^7" }, "optionalPeers": ["async-validator", "axios", "change-case", "drauu", "focus-trap", "fuse.js", "idb-keyval", "jwt-decode", "nprogress", "qrcode", "sortablejs", "universal-cookie"] }, "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g=="], + + "@vueuse/metadata": ["@vueuse/metadata@12.8.2", "", {}, "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A=="], + + "@vueuse/shared": ["@vueuse/shared@12.8.2", "", { "dependencies": { "vue": "^3.5.13" } }, "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w=="], + + "algoliasearch": ["algoliasearch@5.49.1", "", { "dependencies": { "@algolia/abtesting": "1.15.1", "@algolia/client-abtesting": "5.49.1", "@algolia/client-analytics": "5.49.1", "@algolia/client-common": "5.49.1", "@algolia/client-insights": "5.49.1", "@algolia/client-personalization": "5.49.1", "@algolia/client-query-suggestions": "5.49.1", "@algolia/client-search": "5.49.1", "@algolia/ingestion": "1.49.1", "@algolia/monitoring": "1.49.1", "@algolia/recommend": "5.49.1", "@algolia/requester-browser-xhr": "5.49.1", "@algolia/requester-fetch": "5.49.1", "@algolia/requester-node-http": "5.49.1" } }, "sha512-X3Pp2aRQhg4xUC6PQtkubn5NpRKuUPQ9FPDQlx36SmpFwwH2N0/tw4c+NXV3nw3PsgeUs+BuWGP0gjz3TvENLQ=="], + + "birpc": ["birpc@2.9.0", "", {}, "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="], + + "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], + + "character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="], + + "character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="], + + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], + + "copy-anything": ["copy-anything@4.0.5", "", { "dependencies": { "is-what": "^5.2.0" } }, "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], + + "emoji-regex-xs": ["emoji-regex-xs@1.0.0", "", {}, "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "focus-trap": ["focus-trap@7.8.0", "", { "dependencies": { "tabbable": "^6.4.0" } }, "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "hast-util-to-html": ["hast-util-to-html@9.0.5", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "comma-separated-tokens": "^2.0.0", "hast-util-whitespace": "^3.0.0", "html-void-elements": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "stringify-entities": "^4.0.0", "zwitch": "^2.0.4" } }, "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw=="], + + "hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="], + + "hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="], + + "html-void-elements": ["html-void-elements@3.0.0", "", {}, "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg=="], + + "is-what": ["is-what@5.5.0", "", {}, "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mark.js": ["mark.js@8.11.1", "", {}, "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ=="], + + "mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="], + + "micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="], + + "micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="], + + "micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="], + + "micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="], + + "micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="], + + "minisearch": ["minisearch@7.2.0", "", {}, "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg=="], + + "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "oniguruma-to-es": ["oniguruma-to-es@3.1.1", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^6.0.1", "regex-recursion": "^6.0.2" } }, "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ=="], + + "perfect-debounce": ["perfect-debounce@1.0.0", "", {}, "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + + "preact": ["preact@10.28.4", "", {}, "sha512-uKFfOHWuSNpRFVTnljsCluEFq57OKT+0QdOiQo8XWnQ/pSvg7OpX5eNOejELXJMWy+BwM2nobz0FkvzmnpCNsQ=="], + + "property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="], + + "regex": ["regex@6.1.0", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg=="], + + "regex-recursion": ["regex-recursion@6.0.2", "", { "dependencies": { "regex-utilities": "^2.3.0" } }, "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg=="], + + "regex-utilities": ["regex-utilities@2.3.0", "", {}, "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.59.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.59.0", "@rollup/rollup-android-arm64": "4.59.0", "@rollup/rollup-darwin-arm64": "4.59.0", "@rollup/rollup-darwin-x64": "4.59.0", "@rollup/rollup-freebsd-arm64": "4.59.0", "@rollup/rollup-freebsd-x64": "4.59.0", "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", "@rollup/rollup-linux-arm-musleabihf": "4.59.0", "@rollup/rollup-linux-arm64-gnu": "4.59.0", "@rollup/rollup-linux-arm64-musl": "4.59.0", "@rollup/rollup-linux-loong64-gnu": "4.59.0", "@rollup/rollup-linux-loong64-musl": "4.59.0", "@rollup/rollup-linux-ppc64-gnu": "4.59.0", "@rollup/rollup-linux-ppc64-musl": "4.59.0", "@rollup/rollup-linux-riscv64-gnu": "4.59.0", "@rollup/rollup-linux-riscv64-musl": "4.59.0", "@rollup/rollup-linux-s390x-gnu": "4.59.0", "@rollup/rollup-linux-x64-gnu": "4.59.0", "@rollup/rollup-linux-x64-musl": "4.59.0", "@rollup/rollup-openbsd-x64": "4.59.0", "@rollup/rollup-openharmony-arm64": "4.59.0", "@rollup/rollup-win32-arm64-msvc": "4.59.0", "@rollup/rollup-win32-ia32-msvc": "4.59.0", "@rollup/rollup-win32-x64-gnu": "4.59.0", "@rollup/rollup-win32-x64-msvc": "4.59.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg=="], + + "search-insights": ["search-insights@2.17.3", "", {}, "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ=="], + + "shiki": ["shiki@2.5.0", "", { "dependencies": { "@shikijs/core": "2.5.0", "@shikijs/engine-javascript": "2.5.0", "@shikijs/engine-oniguruma": "2.5.0", "@shikijs/langs": "2.5.0", "@shikijs/themes": "2.5.0", "@shikijs/types": "2.5.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + + "speakingurl": ["speakingurl@14.0.1", "", {}, "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ=="], + + "stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="], + + "superjson": ["superjson@2.2.6", "", { "dependencies": { "copy-anything": "^4" } }, "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA=="], + + "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], + + "trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="], + + "unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="], + + "unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="], + + "unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="], + + "unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="], + + "unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="], + + "vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="], + + "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vitepress": ["vitepress@1.6.4", "", { "dependencies": { "@docsearch/css": "3.8.2", "@docsearch/js": "3.8.2", "@iconify-json/simple-icons": "^1.2.21", "@shikijs/core": "^2.1.0", "@shikijs/transformers": "^2.1.0", "@shikijs/types": "^2.1.0", "@types/markdown-it": "^14.1.2", "@vitejs/plugin-vue": "^5.2.1", "@vue/devtools-api": "^7.7.0", "@vue/shared": "^3.5.13", "@vueuse/core": "^12.4.0", "@vueuse/integrations": "^12.4.0", "focus-trap": "^7.6.4", "mark.js": "8.11.1", "minisearch": "^7.1.1", "shiki": "^2.1.0", "vite": "^5.4.14", "vue": "^3.5.13" }, "peerDependencies": { "markdown-it-mathjax3": "^4", "postcss": "^8" }, "optionalPeers": ["markdown-it-mathjax3", "postcss"], "bin": { "vitepress": "bin/vitepress.js" } }, "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg=="], + + "vue": ["vue@3.5.30", "", { "dependencies": { "@vue/compiler-dom": "3.5.30", "@vue/compiler-sfc": "3.5.30", "@vue/runtime-dom": "3.5.30", "@vue/server-renderer": "3.5.30", "@vue/shared": "3.5.30" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg=="], + + "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + } +} diff --git a/docs/guide/api.md b/docs/guide/api.md new file mode 100644 index 00000000..f00d51f5 --- /dev/null +++ b/docs/guide/api.md @@ -0,0 +1,535 @@ +# REST API + +Rustalink implements the Lavalink v4 API. Below are the supported endpoints and their raw responses. + +## GET /v4/info + +Get basic server information. + +**Example request:** +```bash +curl -X GET -H "Authorization: youshallnotpass" "http://localhost:2333/v4/info" +``` + +
+Raw API response (Expand) + +```json +{ + "version": { + "semver": "1.0.5", + "major": 1, + "minor": 0, + "patch": 5, + "preRelease": null + }, + "buildTime": 1773050806122, + "git": { + "branch": "dev", + "commit": "802.....", + "commitTime": 1773050774000 + }, + "jvm": "Rust", + "lavaplayer": "symphonia", + "sourceManagers": [ + "youtube", + "spotify", + "jiosaavn", + "gaana", + "tidal", + "audiomack", + "shazam", + "mixcloud", + "bandcamp", + "reddit", + "audius", + "netease", + "http", + "local" + ], + "filters": [ + "volume", + "equalizer", + "karaoke", + "timescale", + "tremolo", + "vibrato", + "distortion", + "rotation", + "channelMix", + "lowPass", + "echo", + "highPass", + "normalization", + "chorus", + "compressor", + "flanger", + "phaser", + "phonograph", + "reverb", + "spatial", + "pluginFilters" + ], + "plugins": [] +} +``` +
+ +## GET /v4/stats + +Get server statistics. + +**Example request:** +```bash +curl -X GET -H "Authorization: youshallnotpass" "http://localhost:2333/v4/stats" +``` + +
+Raw API response (Expand) + +```json +{ + "players": 0, + "playingPlayers": 0, + "uptime": 298962, + "memory": { + "free": 19219042304, + "used": 49045504, + "allocated": 49045504, + "reservable": 32953487360 + }, + "cpu": { + "cores": 8, + "systemLoad": 0.31881649017333985, + "lavalinkLoad": 0.00006302547175437213 + } +} +``` +
+ +## GET /v4/routeplanner/status + +Get routeplanner status. + +**Example request:** +```bash +curl -X GET -H "Authorization: youshallnotpass" "http://localhost:2333/v4/routeplanner/status" +``` + +
+Raw API response (Expand) + +```json + +``` +
+ +## GET /v4/loadtracks?identifier=ytsearch:never+gonna+give+you+up + +Load a track. + +**Example request:** +```bash +curl -X GET -H "Authorization: youshallnotpass" "http://localhost:2333/v4/loadtracks?identifier=ytsearch:never+gonna+give+you+up" +``` + +
+Raw API response (Expand) + +```json +{ + "loadType": "search", + "data": [ + { + "encoded": "QAAA4wMARFJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAgKE9mZmljaWFsIFZpZGVvKSAoNEsgUmVtYXN0ZXIpAAtSaWNrIEFzdGxleQAAAAAAA0PwAAtkUXc0dzlXZ1hjUQABACtodHRwczovL3d3dy55b3V0dWJlLmNvbS93YXRjaD92PWRRdzR3OVdnWGNRAQA2aHR0cHM6Ly9pLnl0aW1nLmNvbS92aV93ZWJwL2RRdzR3OVdnWGNRL3NkZGVmYXVsdC53ZWJwAAAHeW91dHViZQAAAAAAAAAA", + "info": { + "identifier": "dQw4w9WgXcQ", + "isSeekable": true, + "author": "Rick Astley", + "length": 214000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up (Official Video) (4K Remaster)", + "uri": "https://www.youtube.com/watch?v=dQw4w9WgXcQ", + "artworkUrl": "https://i.ytimg.com/vi_webp/dQw4w9WgXcQ/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAAwQMAJVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAADkFtYXppbmcgTHlyaWNzAAAAAAADQ/AACzdGd0RQMTdYUGxrAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9N0Z3RFAxN1hQbGsBADBodHRwczovL2kueXRpbWcuY29tL3ZpLzdGd0RQMTdYUGxrL3NkZGVmYXVsdC5qcGcAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "7FwDP17XPlk", + "isSeekable": true, + "author": "Amazing Lyrics", + "length": 214000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=7FwDP17XPlk", + "artworkUrl": "https://i.ytimg.com/vi/7FwDP17XPlk/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA4gMAMUluc3VyQUFBbmNlICYgUmljayBBc3RsZXkgTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAAI0NTQUEgSW5zdXJhbmNlIEdyb3VwLCBhIEFBQSBJbnN1cmVyAAAAAAAA/egAC0d0TDFodWluOUVFAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9R3RMMWh1aW45RUUBADBodHRwczovL2kueXRpbWcuY29tL3ZpL0d0TDFodWluOUVFL3NkZGVmYXVsdC5qcGcAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "GtL1huin9EE", + "isSeekable": true, + "author": "CSAA Insurance Group, a AAA Insurer", + "length": 65000, + "isStream": false, + "position": 0, + "title": "InsurAAAnce & Rick Astley Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=GtL1huin9EE", + "artworkUrl": "https://i.ytimg.com/vi/GtL1huin9EE/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA0QMAQE5ldmVyIEdvbm5hIEdpdmUgWW91IFVwIHwgUmljayBBc3RsZXkgUm9ja3MgTmV3IFllYXIncyBFdmUgLSBCQkMAA0JCQwAAAAAAA5IQAAtYR3hJRTFocjB3NAABACtodHRwczovL3d3dy55b3V0dWJlLmNvbS93YXRjaD92PVhHeElFMWhyMHc0AQAwaHR0cHM6Ly9pLnl0aW1nLmNvbS92aS9YR3hJRTFocjB3NC9zZGRlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA", + "info": { + "identifier": "XGxIE1hr0w4", + "isSeekable": true, + "author": "BBC", + "length": 234000, + "isStream": false, + "position": 0, + "title": "Never Gonna Give You Up | Rick Astley Rocks New Year's Eve - BBC", + "uri": "https://www.youtube.com/watch?v=XGxIE1hr0w4", + "artworkUrl": "https://i.ytimg.com/vi/XGxIE1hr0w4/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAAwQMAJEZhbWlseSBHdXkgLSBOZXZlciBHb25uYSBHaXZlIFlvdSBVcAAPQXJyaWYgSmFsYWx1ZGluAAAAAAABpeAAC0RzQzhqUVhSYlFFAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9RHNDOGpRWFJiUUUBADBodHRwczovL2kueXRpbWcuY29tL3ZpL0RzQzhqUVhSYlFFL2hxZGVmYXVsdC5qcGcAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "DsC8jQXRbQE", + "isSeekable": true, + "author": "Arrif Jalaludin", + "length": 108000, + "isStream": false, + "position": 0, + "title": "Family Guy - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=DsC8jQXRbQE", + "artworkUrl": "https://i.ytimg.com/vi/DsC8jQXRbQE/hqdefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAAxgMAJ05ldmVyIEdvbm5hIEdpdmUgWW91IFVwICgyMDIyIFJlbWFzdGVyKQALUmljayBBc3RsZXkAAAAAAAND8AALM0JGVGlvNTI5NncAAQAraHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj0zQkZUaW81Mjk2dwEANmh0dHBzOi8vaS55dGltZy5jb20vdmlfd2VicC8zQkZUaW81Mjk2dy9zZGRlZmF1bHQud2VicAAAB3lvdXR1YmUAAAAAAAAAAA==", + "info": { + "identifier": "3BFTio5296w", + "isSeekable": true, + "author": "Rick Astley", + "length": 214000, + "isStream": false, + "position": 0, + "title": "Never Gonna Give You Up (2022 Remaster)", + "uri": "https://www.youtube.com/watch?v=3BFTio5296w", + "artworkUrl": "https://i.ytimg.com/vi_webp/3BFTio5296w/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA2QMAOuOAkOaXpeacrOiqnuWtl+W5leOAkVJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAAC1JpY2sgQXN0bGV5AAAAAAADQ/AAC1JyRVN2U1JOcGVvAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9UnJFU3ZTUk5wZW8BADZodHRwczovL2kueXRpbWcuY29tL3ZpX3dlYnAvUnJFU3ZTUk5wZW8vc2RkZWZhdWx0LndlYnAAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "RrESvSRNpeo", + "isSeekable": true, + "author": "Rick Astley", + "length": 214000, + "isStream": false, + "position": 0, + "title": "【日本語字幕】Rick Astley - Never Gonna Give You Up", + "uri": "https://www.youtube.com/watch?v=RrESvSRNpeo", + "artworkUrl": "https://i.ytimg.com/vi_webp/RrESvSRNpeo/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAAywMALk5ldmVyIEdvbm5hIEdpdmUgWW91IFVwIC0gUmljayBBc3RsZXkgKEx5cmljcykAD0ludml0ZWQgS2luZ2RvbQAAAAAAA0PwAAtNT2NRYVI3X1dybwABACtodHRwczovL3d3dy55b3V0dWJlLmNvbS93YXRjaD92PU1PY1FhUjdfV3JvAQAwaHR0cHM6Ly9pLnl0aW1nLmNvbS92aS9NT2NRYVI3X1dyby9zZGRlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA", + "info": { + "identifier": "MOcQaR7_Wro", + "isSeekable": true, + "author": "Invited Kingdom", + "length": 214000, + "isStream": false, + "position": 0, + "title": "Never Gonna Give You Up - Rick Astley (Lyrics)", + "uri": "https://www.youtube.com/watch?v=MOcQaR7_Wro", + "artworkUrl": "https://i.ytimg.com/vi/MOcQaR7_Wro/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAAyQMANUJhcnJ5IFdoaXRlIC0gTmV2ZXIgTmV2ZXIgR29ubmEgR2l2ZSBZYSBVcCDigKIgVG9wUG9wAAZUb3BQb3AAAAAAAAOhsAALSzV6UDdlUWx0REUAAQAraHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1LNXpQN2VRbHRERQEAMGh0dHBzOi8vaS55dGltZy5jb20vdmkvSzV6UDdlUWx0REUvc2RkZWZhdWx0LmpwZwAAB3lvdXR1YmUAAAAAAAAAAA==", + "info": { + "identifier": "K5zP7eQltDE", + "isSeekable": true, + "author": "TopPop", + "length": 238000, + "isStream": false, + "position": 0, + "title": "Barry White - Never Never Gonna Give Ya Up • TopPop", + "uri": "https://www.youtube.com/watch?v=K5zP7eQltDE", + "artworkUrl": "https://i.ytimg.com/vi/K5zP7eQltDE/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAAvAMAHU5ldmVyLCBOZXZlciBHb25uYSBHaXZlIFlhIFVwAAtCYXJyeSBXaGl0ZQAAAAAAB0dIAAtRcGJoU2xjZV9lawABACtodHRwczovL3d3dy55b3V0dWJlLmNvbS93YXRjaD92PVFwYmhTbGNlX2VrAQA2aHR0cHM6Ly9pLnl0aW1nLmNvbS92aV93ZWJwL1FwYmhTbGNlX2VrL3NkZGVmYXVsdC53ZWJwAAAHeW91dHViZQAAAAAAAAAA", + "info": { + "identifier": "QpbhSlce_ek", + "isSeekable": true, + "author": "Barry White", + "length": 477000, + "isStream": false, + "position": 0, + "title": "Never, Never Gonna Give Ya Up", + "uri": "https://www.youtube.com/watch?v=QpbhSlce_ek", + "artworkUrl": "https://i.ytimg.com/vi_webp/QpbhSlce_ek/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA4wMARFJpY2sgQXN0bGV5ICAtIE5ldmVyIEdvbm5hIEdpdmUgWW91IFVwIChQaWFub2ZvcnRlKSAoT2ZmaWNpYWwgQXVkaW8pAAtSaWNrIEFzdGxleQAAAAAAAzRQAAtHSE1qRDBMcDVEWQABACtodHRwczovL3d3dy55b3V0dWJlLmNvbS93YXRjaD92PUdITWpEMExwNURZAQA2aHR0cHM6Ly9pLnl0aW1nLmNvbS92aV93ZWJwL0dITWpEMExwNURZL3NkZGVmYXVsdC53ZWJwAAAHeW91dHViZQAAAAAAAAAA", + "info": { + "identifier": "GHMjD0Lp5DY", + "isSeekable": true, + "author": "Rick Astley", + "length": 210000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up (Pianoforte) (Official Audio)", + "uri": "https://www.youtube.com/watch?v=GHMjD0Lp5DY", + "artworkUrl": "https://i.ytimg.com/vi_webp/GHMjD0Lp5DY/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA+gMAWVLEq8SLYSDEkmFzdGzEk2FoIC0gTmV2ZXIgZ29ubmEgZ2l2ZSB5b3UgdXAgQ292ZXIgSW4gT2xkIEVuZ2xpc2guIEJhcmRjb3JlL01lZGlldmFsIHN0eWxlABN0aGVfbWlyYWNsZV9hbGlnbmVyAAAAAAACfLgAC2NFcmdNSlNncHYwAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9Y0VyZ01KU2dwdjABADBodHRwczovL2kueXRpbWcuY29tL3ZpL2NFcmdNSlNncHYwL3NkZGVmYXVsdC5qcGcAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "cErgMJSgpv0", + "isSeekable": true, + "author": "the_miracle_aligner", + "length": 163000, + "isStream": false, + "position": 0, + "title": "Rīċa Ēastlēah - Never gonna give you up Cover In Old English. Bardcore/Medieval style", + "uri": "https://www.youtube.com/watch?v=cErgMJSgpv0", + "artworkUrl": "https://i.ytimg.com/vi/cErgMJSgpv0/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA3gMAP1JpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAgKE9mZmljaWFsIEFuaW1hdGVkIFZpZGVvKQALUmljayBBc3RsZXkAAAAAAANACAALTExGaEthcW5Xd2sAAQAraHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj1MTEZoS2Fxbld3awEANmh0dHBzOi8vaS55dGltZy5jb20vdmlfd2VicC9MTEZoS2Fxbld3ay9zZGRlZmF1bHQud2VicAAAB3lvdXR1YmUAAAAAAAAAAA==", + "info": { + "identifier": "LLFhKaqnWwk", + "isSeekable": true, + "author": "Rick Astley", + "length": 213000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up (Official Animated Video)", + "uri": "https://www.youtube.com/watch?v=LLFhKaqnWwk", + "artworkUrl": "https://i.ytimg.com/vi_webp/LLFhKaqnWwk/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA1QMALlJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAgKEx5cmljcykAE1lvdW5nIFBpbGdyaW0gTXVzaWMAAAAAAAN6oAALNlBMYXRQTW94R3cAAQAraHR0cHM6Ly93d3cueW91dHViZS5jb20vd2F0Y2g/dj02UExhdFBNb3hHdwEANmh0dHBzOi8vaS55dGltZy5jb20vdmlfd2VicC82UExhdFBNb3hHdy9zZGRlZmF1bHQud2VicAAAB3lvdXR1YmUAAAAAAAAAAA==", + "info": { + "identifier": "6PLatPMoxGw", + "isSeekable": true, + "author": "Young Pilgrim Music", + "length": 228000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up (Lyrics)", + "uri": "https://www.youtube.com/watch?v=6PLatPMoxGw", + "artworkUrl": "https://i.ytimg.com/vi_webp/6PLatPMoxGw/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA1gMAOExpc2EgU3RhbnNmaWVsZCAtIE5ldmVyLCBOZXZlciBHb25uYSBHaXZlIFlvdSBVcCAoVmlkZW8pABBMaXNhU3RhbnNmaWVsZHR2AAAAAAAEFuAAC3B6WXBIWEN1bUlJAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9cHpZcEhYQ3VtSUkBADBodHRwczovL2kueXRpbWcuY29tL3ZpL3B6WXBIWEN1bUlJL3NkZGVmYXVsdC5qcGcAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "pzYpHXCumII", + "isSeekable": true, + "author": "LisaStansfieldtv", + "length": 268000, + "isStream": false, + "position": 0, + "title": "Lisa Stansfield - Never, Never Gonna Give You Up (Video)", + "uri": "https://www.youtube.com/watch?v=pzYpHXCumII", + "artworkUrl": "https://i.ytimg.com/vi/pzYpHXCumII/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA6QMAT1JpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAgW1JlbWFzdGVyZWQgSW4gNEtdIChPZmZpY2lhbCBNdXNpYyBWaWRlbykADEVuam95IGl08J+kjQAAAAAAAzwgAAtMUTR3OXhpSGtyWQABACtodHRwczovL3d3dy55b3V0dWJlLmNvbS93YXRjaD92PUxRNHc5eGlIa3JZAQAwaHR0cHM6Ly9pLnl0aW1nLmNvbS92aS9MUTR3OXhpSGtyWS9zZGRlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA", + "info": { + "identifier": "LQ4w9xiHkrY", + "isSeekable": true, + "author": "Enjoy it🤍", + "length": 212000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up [Remastered In 4K] (Official Music Video)", + "uri": "https://www.youtube.com/watch?v=LQ4w9xiHkrY", + "artworkUrl": "https://i.ytimg.com/vi/LQ4w9xiHkrY/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAAywMALk5ldmVyIEdvbm5hIEdpdmUgWW91IFVwIChMeXJpY3MpIC0gUmljayBBc3RsZXkAD0dpb3Zhbm5hIExvemFubwAAAAAAA0AIAAtTYllYa09Bb1pwSQABACtodHRwczovL3d3dy55b3V0dWJlLmNvbS93YXRjaD92PVNiWVhrT0FvWnBJAQAwaHR0cHM6Ly9pLnl0aW1nLmNvbS92aS9TYllYa09Bb1pwSS9zZGRlZmF1bHQuanBnAAAHeW91dHViZQAAAAAAAAAA", + "info": { + "identifier": "SbYXkOAoZpI", + "isSeekable": true, + "author": "Giovanna Lozano", + "length": 213000, + "isStream": false, + "position": 0, + "title": "Never Gonna Give You Up (Lyrics) - Rick Astley", + "uri": "https://www.youtube.com/watch?v=SbYXkOAoZpI", + "artworkUrl": "https://i.ytimg.com/vi/SbYXkOAoZpI/sddefault.jpg", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA4gMAO1JpY2sgQXN0bGV5IC0gTkVWRVIgR09OTkEgR0lWRSBZT1UgVVAgKFN1bmcgYnkgMTY5IE1vdmllcyEpABNUaGUgVW51c3VhbCBTdXNwZWN0AAAAAAACm/gAC2VweVJVcDBCaHJBAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9ZXB5UlVwMEJockEBADZodHRwczovL2kueXRpbWcuY29tL3ZpX3dlYnAvZXB5UlVwMEJockEvc2RkZWZhdWx0LndlYnAAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "epyRUp0BhrA", + "isSeekable": true, + "author": "The Unusual Suspect", + "length": 171000, + "isStream": false, + "position": 0, + "title": "Rick Astley - NEVER GONNA GIVE YOU UP (Sung by 169 Movies!)", + "uri": "https://www.youtube.com/watch?v=epyRUp0BhrA", + "artworkUrl": "https://i.ytimg.com/vi_webp/epyRUp0BhrA/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + }, + { + "encoded": "QAAA3wMAQFJpY2sgQXN0bGV5IC0gTmV2ZXIgR29ubmEgR2l2ZSBZb3UgVXAgKFBpYW5vZm9ydGUpIChQZXJmb3JtYW5jZSkAC1JpY2sgQXN0bGV5AAAAAAADX0gAC3JUZ2E0MXIzYTRzAAEAK2h0dHBzOi8vd3d3LnlvdXR1YmUuY29tL3dhdGNoP3Y9clRnYTQxcjNhNHMBADZodHRwczovL2kueXRpbWcuY29tL3ZpX3dlYnAvclRnYTQxcjNhNHMvc2RkZWZhdWx0LndlYnAAAAd5b3V0dWJlAAAAAAAAAAA=", + "info": { + "identifier": "rTga41r3a4s", + "isSeekable": true, + "author": "Rick Astley", + "length": 221000, + "isStream": false, + "position": 0, + "title": "Rick Astley - Never Gonna Give You Up (Pianoforte) (Performance)", + "uri": "https://www.youtube.com/watch?v=rTga41r3a4s", + "artworkUrl": "https://i.ytimg.com/vi_webp/rTga41r3a4s/sddefault.webp", + "isrc": null, + "sourceName": "youtube" + }, + "pluginInfo": {}, + "userData": {} + } + ] +} +``` +
+ +## GET /v4/sessions/xyz + +Get session information (returns 404/Error if invalid). + +**Example request:** +```bash +curl -X GET -H "Authorization: youshallnotpass" "http://localhost:2333/v4/sessions/xyz" +``` + +
+Raw API response (Expand) + +```json +{ + "timestamp": 1773058695473, + "status": 404, + "error": "Not Found", + "message": "Session not found: xyz", + "path": "/v4/sessions/xyz" +} +``` +
+ +## PATCH /v4/sessions/xyz + +Update session properties. + +**Example request:** +```bash +curl -X PATCH -H "Authorization: youshallnotpass" -H "Content-Type: application/json" -d '{"resuming": true, "timeout": 60}' "http://localhost:2333/v4/sessions/xyz" +``` + +
+Raw API response (Expand) + +```json +{ + "timestamp": 1773058695481, + "status": 404, + "error": "Not Found", + "message": "Session not found", + "path": "/v4/sessions/xyz" +} +``` +
+ diff --git a/docs/guide/architecture.md b/docs/guide/architecture.md new file mode 100644 index 00000000..a5d4ef9d --- /dev/null +++ b/docs/guide/architecture.md @@ -0,0 +1,20 @@ +# Architecture + +Rustalink is built on a modern, asynchronous foundation using Tokio, ensuring low latency and high concurrency. + +## Core Components + +- **REST API:** Powered by Axum for lightning-fast JSON processing. +- **WebSocket Manager:** Real-time state synchronization and command routing. +- **Audio Pipeline:** Efficient raw packet decoding, routing, and encoding. +- **Filter Engine:** 32-bit floating-point DSP engine for studio-grade effects. + +## Data Flow + +```mermaid +graph LR + Client --> API[REST / WS] + API --> Pipeline + Pipeline --> Filters + Filters --> Discord[Discord Voice] +``` diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md new file mode 100644 index 00000000..f4b0a689 --- /dev/null +++ b/docs/guide/configuration.md @@ -0,0 +1,18 @@ +# Configuration + +Rustalink relies on a `config.toml` file for predictable, readable configuration. + +## Example + +```toml +[server] +port = 2333 +address = "0.0.0.0" +password = "youshallnotpass" + +[audio] +item_timeout_ms = 3000 + +[plugins] +# Plugin configurations go here +``` diff --git a/docs/guide/docker.md b/docs/guide/docker.md new file mode 100644 index 00000000..0455748e --- /dev/null +++ b/docs/guide/docker.md @@ -0,0 +1,26 @@ +# Docker + +The recommended, zero-hassle way to deploy Rustalink. + +## `docker-compose.yml` + +```yaml + +services: + rustalink: + image: ghcr.io/appujet/rustalink:latest + container_name: rustalink + restart: unless-stopped + ports: + - "2333:2333" + environment: + - RUST_LOG=info + volumes: + - ./config.toml:/app/config.toml +``` + +## Start the Container + +```bash +docker-compose up -d +``` diff --git a/docs/guide/filters.md b/docs/guide/filters.md new file mode 100644 index 00000000..182d94e0 --- /dev/null +++ b/docs/guide/filters.md @@ -0,0 +1,15 @@ +# Filters + +Rustalink features a suite of built-in, `f32`-precision audio filters, eliminating integer quantization noise for crystal-clear playback. + +## Available Filters + +- **Volume:** Gain adjustments. +- **Equalizer:** 15-band EQ. +- **Timescale:** Modify speed, pitch, and rate seamlessly. +- **Tremolo:** Periodic volume fluctuation. +- **Vibrato:** Periodic pitch fluctuation. +- **Rotation:** 8D spatial panning. +- **Distortion:** Saturation and clipping effects. +- **ChannelMix:** Custom audio matrix layouts. +- **LowPass:** High-frequency softening. diff --git a/docs/guide/installation.md b/docs/guide/installation.md new file mode 100644 index 00000000..d584f83e --- /dev/null +++ b/docs/guide/installation.md @@ -0,0 +1,55 @@ +# Installation + +Get Rustalink up and running in minutes. Whether you prefer building from source or using our pre-packaged binaries, we've got you covered. + +## Prerequisites + +Before building from source, ensure you have the following installed on your system: + +- **[Rust](https://rustup.rs/)** (latest stable toolchain) +- **CMake** (required for building certain C dependencies) +- **OpenSSL** (required for secure connections) + +## Install from Releases + +The fastest way to get started is by downloading a pre-compiled binary from our GitHub Releases page. + +1. Navigate to the [Rustalink Releases](https://github.com/appujet/Rustalink/releases) page. +2. Download the latest archive for your operating system (Windows, macOS, or Linux). +3. Extract the binary and place it in your desired folder. +4. Download the `config.example.toml`, rename it to `config.toml`, and place it in the same directory. +5. Execute the binary: `./rustalink` (or `rustalink.exe` on Windows). + +## Build from Source + +Compiling Rustalink from source guarantees you get the very latest performance optimizations specific to your machine architecture. + +1. **Clone the repository:** + + ```bash + git clone https://github.com/appujet/Rustalink.git + cd Rustalink + ``` + +2. **Build the project:** + This step will download dependencies and compile the server. It might take a few minutes on the first run. + + ```bash + cargo build --release + ``` + +## Run the Server + +Before starting, ensure your `config.toml` is correctly configured in your working directory. You can copy the example configuration: + +```bash +cp config.example.toml config.toml +``` + +Start the compiled binary: + +```bash +./target/release/rustalink +``` + +Once running, you should see logs indicating that the server is listening for connections. You're now ready to connect your Lavalink clients! diff --git a/docs/guide/pterodactyl.md b/docs/guide/pterodactyl.md new file mode 100644 index 00000000..355f6aaf --- /dev/null +++ b/docs/guide/pterodactyl.md @@ -0,0 +1,115 @@ +# Pterodactyl Hosting + +This guide explains how to host your own Rustalink node on a Pterodactyl panel. + +## Prerequisites + +- A Pterodactyl panel with administrative access (to import the egg). +- A node with Docker support. + +## Importing the Egg + +1. Download the `egg-rustalink.json` file from the button below, or copy the content from the configuration block. + + Download egg-rustalink.json + + ::: details Click to expand egg configuration + + ```json + { + "_comment": "do not edit this file, use the pterodactyl panel to edit the egg", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2026-03-09T13:15:00Z", + "name": "Rustalink", + "author": "sdipedit@gmail.com", + "description": "Rustalink is a high-performance, standalone Discord audio sending node written in Rust.", + "features": null, + "docker_images": { + "ghcr.io/bongodevs/rustalink:latest": "ghcr.io/bongodevs/rustalink:latest" + }, + "file_denylist": [], + "startup": "/app/rustalink", + "config": { + "files": { + "config.toml": { + "parser": "toml", + "find": { + "server.address": "0.0.0.0", + "server.port": "{{server.build.default.port}}", + "server.authorization": "{{env.SERVER_AUTH}}" + } + } + }, + "startup": { + "done": "Listening on" + }, + "logs": { + "custom": true, + "location": "logs/rustalink.log" + }, + "stop": "^C" + }, + "scripts": { + "installation": { + "container": "debian:bookworm-slim", + "entrypoint": "bash", + "script": "#!/bin/bash\n# Installation script for Rustalink\n\napt update\napt install -y curl\n\nif [ ! -f config.toml ]; then\n echo \"Downloading default config...\"\n curl -sSL https://raw.githubusercontent.com/bongodevs/Rustalink/HEAD/config.example.toml -o config.toml\nfi" + } + }, + "variables": [ + { + "name": "Server Password", + "description": "The password required to connect to this Rustalink node.", + "env_variable": "SERVER_AUTH", + "default_value": "youshallnotpass", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|min:1" + } + ] + } + ``` + + ::: + +2. Log in to your Pterodactyl panel as an administrator. +3. Go to **Nests** in the admin sidebar. +4. Select a nest (e.g., "Generic") or create a new one. +5. Click **Import Egg** in the top right. +6. Select the `egg-rustalink.json` file you downloaded earlier. +7. Click **Import**. + +## Creating the Server + +1. Go to the **Servers** section in the admin panel. +2. Click **Create New**. +3. Fill in the server details (Name, Owner, etc.). +4. In the **Nest Configuration** section, select the nest where you imported the egg and choose **Rustalink** as the egg. +5. Set the **Docker Image** to `ghcr.io/bongodevs/rustalink:latest`. +6. Configure the resource limits as needed. +7. Click **Create Server**. + +## Configuration + +Once the server is created, Pterodactyl will automatically generate a `config.toml` file if it doesn't exist (using the installation script). + +### Environment Variables + +You can configure basic settings through the **Startup** tab in the server console: + +- **Server Password**: Set the `SERVER_AUTH` variable to your desired authorization token. +- **Server Port**: This is automatically linked to the server's primary allocation. + +### Advanced Configuration + +For more advanced settings, you can edit the `config.toml` file directly via the **File Manager**. + +> [!TIP] +> Make sure the `server.address` is set to `0.0.0.0` inside `config.toml` to allow external connections (the egg handles this by default). + +## Support + +If you encounter any issues, feel free to open an issue on our [GitHub repository](https://github.com/bongodevs/Rustalink/issues). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..88eba12e --- /dev/null +++ b/docs/index.md @@ -0,0 +1,4 @@ +--- +layout: doc +--- +# WIP \ No newline at end of file diff --git a/docs/package.json b/docs/package.json new file mode 100644 index 00000000..074a81ed --- /dev/null +++ b/docs/package.json @@ -0,0 +1,15 @@ +{ + "name": "docs", + "version": "1.0.0", + "description": "Rustalink Documentation", + "type": "module", + "scripts": { + "docs:dev": "vitepress dev", + "docs:build": "vitepress build", + "docs:preview": "vitepress preview" + }, + "dependencies": { + "vitepress": "^1.6.4", + "vue": "^3.5.30" + } +} \ No newline at end of file diff --git a/docs/public/egg-rustalink.json b/docs/public/egg-rustalink.json new file mode 100644 index 00000000..b644da71 --- /dev/null +++ b/docs/public/egg-rustalink.json @@ -0,0 +1,55 @@ +{ + "_comment": "do not edit this file, use the pterodactyl panel to edit the egg", + "meta": { + "version": "PTDL_v2", + "update_url": null + }, + "exported_at": "2026-03-09T13:15:00Z", + "name": "Rustalink", + "author": "sdipedit@gmail.com", + "description": "Rustalink is a high-performance, standalone Discord audio sending node written in Rust.", + "features": null, + "docker_images": { + "ghcr.io/bongodevs/rustalink:latest": "ghcr.io/bongodevs/rustalink:latest" + }, + "file_denylist": [], + "startup": "/app/rustalink", + "config": { + "files": { + "config.toml": { + "parser": "toml", + "find": { + "server.address": "0.0.0.0", + "server.port": "{{server.build.default.port}}", + "server.authorization": "{{env.SERVER_AUTH}}" + } + } + }, + "startup": { + "done": "Listening on" + }, + "logs": { + "custom": true, + "location": "logs/rustalink.log" + }, + "stop": "^C" + }, + "scripts": { + "installation": { + "container": "debian:bookworm-slim", + "entrypoint": "bash", + "script": "#!/bin/bash\n# Installation script for Rustalink\n\napt update\napt install -y curl\n\nif [ ! -f config.toml ]; then\n echo \"Downloading default config...\"\n curl -sSL https://raw.githubusercontent.com/bongodevs/Rustalink/HEAD/config.example.toml -o config.toml\nfi" + } + }, + "variables": [ + { + "name": "Server Password", + "description": "The password required to connect to this Rustalink node.", + "env_variable": "SERVER_AUTH", + "default_value": "youshallnotpass", + "user_viewable": true, + "user_editable": true, + "rules": "required|string|min:1" + } + ] +} diff --git a/docs/public/logo.svg b/docs/public/logo.svg new file mode 100644 index 00000000..4a5fd183 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,430 @@ + + + +image/svg+xml diff --git a/src/audio/constants.rs b/src/audio/constants.rs index 0af33469..80f849d8 100644 --- a/src/audio/constants.rs +++ b/src/audio/constants.rs @@ -30,13 +30,13 @@ pub const LAYER_BUFFER_SIZE: usize = 1_024 * 1_024; // ── Segmented remote reader ────────────────────────────────────────────────── -pub const CHUNK_SIZE: usize = 128 * 1_024; -pub const PREFETCH_CHUNKS: usize = 2; +pub const CHUNK_SIZE: usize = 256 * 1_024; +pub const PREFETCH_CHUNKS: usize = 4; pub const MAX_CONCURRENT_FETCHES: usize = 2; pub const HTTP_CLIENT_TIMEOUT_SECS: u64 = 15; pub const MAX_FETCH_RETRIES: u32 = 5; pub const WORKER_IDLE_MS: u64 = 50; -pub const FETCH_WAIT_MS: u64 = 500; +pub const FETCH_WAIT_MS: u64 = 250; pub const PROBE_TIMEOUT_SECS: u64 = 10; // ── HttpSource ─────────────────────────────────────────────────────────────── diff --git a/src/audio/demux/format.rs b/src/audio/demux/format.rs index 6d2c62df..b6b84d17 100644 --- a/src/audio/demux/format.rs +++ b/src/audio/demux/format.rs @@ -1,5 +1,3 @@ -//! Audio format detection via header byte sniffing. - use crate::common::types::AudioFormat; /// Sniff the container format from the first bytes of arbitrary data. diff --git a/src/audio/demux/mod.rs b/src/audio/demux/mod.rs index 98c9d2ea..6a550e88 100644 --- a/src/audio/demux/mod.rs +++ b/src/audio/demux/mod.rs @@ -1,17 +1,3 @@ -//! Demux layer — format detection and container parsing. -//! -//! # Usage -//! -//! ```rust -//! use crate::audio::demux::{detect_format, AudioFormat}; -//! -//! let header = &bytes[..12]; // first bytes of the stream -//! match detect_format(header) { -//! AudioFormat::WebmOpus => { /* use WebmOpusDemuxer */ } -//! _ => { /* use AudioProcessor transcode path */ } -//! } -//! ``` - pub mod format; pub mod webm_opus; @@ -29,9 +15,7 @@ pub use webm_opus::WebmOpusDemuxer; use crate::audio::constants::{MIXER_CHANNELS, TARGET_SAMPLE_RATE}; pub use crate::common::types::AudioFormat; -/// Resolved demux result returned by `open_format`. pub enum DemuxResult { - /// Symphonia-probed format reader + selected track id + codec decoder. Transcode { format: Box, track_id: u32, @@ -41,9 +25,6 @@ pub enum DemuxResult { }, } -/// Open a media source and detect its format. -/// -/// Returns a `DemuxResult` describing the decode path, or a symphonia `Error`. pub fn open_format( source: Box, kind: Option, diff --git a/src/audio/filters/timescale.rs b/src/audio/filters/timescale.rs index a0068075..973170c6 100644 --- a/src/audio/filters/timescale.rs +++ b/src/audio/filters/timescale.rs @@ -49,7 +49,7 @@ impl TimescaleFilter { let num_input_samples = self.input_buffer.len(); let num_input_frames = num_input_samples / 2; - + if num_input_frames < 4 { return Vec::new(); } @@ -87,7 +87,7 @@ impl TimescaleFilter { let consumed_frames = self.position.floor() as usize; let keep_from_frame = consumed_frames.saturating_sub(1); - + if keep_from_frame > 0 { let samples_to_drain = keep_from_frame * 2; if samples_to_drain < self.input_buffer.len() { diff --git a/src/audio/source/client.rs b/src/audio/source/client.rs index e2cd1307..daefc2bc 100644 --- a/src/audio/source/client.rs +++ b/src/audio/source/client.rs @@ -14,7 +14,7 @@ pub fn create_client( let mut builder = Client::builder() .user_agent(user_agent) .connect_timeout(Duration::from_secs(5)) - .read_timeout(Duration::from_secs(30)) + .read_timeout(Duration::from_secs(8)) .tcp_nodelay(true) .tcp_keepalive(Duration::from_secs(25)) .pool_max_idle_per_host(64) diff --git a/src/audio/source/segmented.rs b/src/audio/source/segmented.rs index 931e763d..878828f9 100644 --- a/src/audio/source/segmented.rs +++ b/src/audio/source/segmented.rs @@ -274,6 +274,10 @@ async fn fetch_worker( claimed } .map(|(idx, retries)| { + debug!( + "Worker {}: claiming chunk {} (retry={})", + worker_id, idx, retries + ); state.chunks.insert(idx, ChunkState::Downloading); (idx, retries, total_len) }) @@ -297,7 +301,23 @@ async fn fetch_worker( match fetch_chunk(&client, &url, offset, size).await { Ok(bytes) => { - let actual = bytes.len(); + let actual = bytes.len() as u64; + if actual != size { + warn!( + "Worker {}: partial fetch for chunk {} (got {}/{} bytes)", + worker_id, idx, actual, size + ); + requeue_or_fatal( + lock, + cvar, + idx, + prior_retries, + &format!("partial fetch: {}/{} bytes", actual, size), + ); + tokio::time::sleep(Duration::from_millis(FETCH_WAIT_MS)).await; + continue; + } + let mut state = lock.lock(); state.chunks.insert(idx, ChunkState::Ready(bytes)); trace!( @@ -340,10 +360,12 @@ fn requeue_or_fatal( ) { let mut state = lock.lock(); if prior_retries >= MAX_FETCH_RETRIES { - state.fatal_error = Some(format!( + let msg = format!( "Chunk {}: permanently failed after {} retries: {}", idx, prior_retries, error - )); + ); + warn!("SegmentedSource: fatal error - {}", msg); + state.fatal_error = Some(msg); } else { state .chunks diff --git a/src/config/server.rs b/src/config/server.rs index d9cee819..b34673b2 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -81,6 +81,37 @@ pub struct RoutePlannerConfig { #[serde(default)] pub struct MirrorsConfig { pub providers: Vec, + pub best_match: BestMatchConfig, +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(default)] +pub struct BestMatchConfig { + pub scoring: bool, + pub throttled_prefixes: Vec, + pub min_similarity: f64, + pub high_confidence: f64, + pub immediate_use: f64, + pub weight_title: f64, + pub weight_artist: f64, + pub weight_duration: f64, + pub duration_tolerance_ms: u64, +} + +impl Default for BestMatchConfig { + fn default() -> Self { + Self { + scoring: true, + throttled_prefixes: vec!["ytmsearch:".into(), "ytsearch:".into()], + min_similarity: 0.50, + high_confidence: 0.75, + immediate_use: 0.88, + weight_title: 0.50, + weight_artist: 0.30, + weight_duration: 0.20, + duration_tolerance_ms: 3_000, + } + } } #[derive(Debug, Deserialize, Serialize, Clone, Default)] diff --git a/src/gateway/encryption.rs b/src/gateway/encryption.rs index d93670d1..cd12bdf5 100644 --- a/src/gateway/encryption.rs +++ b/src/gateway/encryption.rs @@ -28,10 +28,13 @@ pub struct DaveHandler { external_sender_set: bool, pending_proposals: Vec>, was_ready: bool, + recognized_users: HashSet, } impl DaveHandler { pub fn new(user_id: UserId, channel_id: ChannelId) -> Self { + let mut recognized_users = HashSet::new(); + recognized_users.insert(user_id); Self { session: None, user_id, @@ -41,10 +44,32 @@ impl DaveHandler { external_sender_set: false, pending_proposals: Vec::new(), was_ready: false, + recognized_users, } } + pub fn add_users(&mut self, uids: &[u64]) { + for &uid in uids { + self.recognized_users.insert(UserId(uid)); + } + debug!("DAVE adding users: {:?}", uids); + } + + pub fn remove_user(&mut self, uid: u64) { + self.recognized_users.remove(&UserId(uid)); + debug!("DAVE removing user: {}", uid); + } + + pub fn set_protocol_version(&mut self, version: u16) { + self.protocol_version = version; + } + pub fn setup_session(&mut self, version: u16) -> AnyResult> { + if version == 0 { + self.reset(); + return Ok(Vec::new()); + } + let nz_version = NonZeroU16::new(version).unwrap_or(DAVE_MIN_VERSION); let session = if let Some(s) = &mut self.session { @@ -98,19 +123,17 @@ impl DaveHandler { } } - pub fn prepare_epoch(&mut self, epoch: u64, protocol_version: u16) { - if epoch == 1 - && let Err(e) = self.setup_session(protocol_version) - { - warn!("DAVE prepare_epoch setup failed: {e}"); + pub fn prepare_epoch(&mut self, epoch: u64, protocol_version: u16) -> Option> { + if epoch == 1 { + match self.setup_session(protocol_version) { + Ok(kp) => return Some(kp), + Err(e) => warn!("DAVE prepare_epoch setup failed: {e}"), + } } + None } - pub fn process_external_sender( - &mut self, - data: &[u8], - connected_users: &HashSet, - ) -> AnyResult>> { + pub fn process_external_sender(&mut self, data: &[u8]) -> AnyResult>> { let mut responses = Vec::new(); if let Some(session) = &mut self.session { @@ -122,9 +145,10 @@ impl DaveHandler { "DAVE processing {} buffered proposals", self.pending_proposals.len() ); + let user_ids: Vec = self.recognized_users.iter().map(|u| u.0).collect(); for prop_data in std::mem::take(&mut self.pending_proposals) { if let Ok(Some(res)) = - Self::do_process_proposals(session, &prop_data, connected_users) + Self::do_process_proposals(session, &prop_data, &user_ids) { responses.push(res); } @@ -165,11 +189,7 @@ impl DaveHandler { Ok(transition_id) } - pub fn process_proposals( - &mut self, - data: &[u8], - connected_users: &HashSet, - ) -> AnyResult>> { + pub fn process_proposals(&mut self, data: &[u8]) -> AnyResult>> { if data.is_empty() { return Err(short_payload_err("DAVE proposals")); } @@ -188,13 +208,14 @@ impl DaveHandler { Some(s) => s, None => return Ok(None), }; - Self::do_process_proposals(session, data, connected_users) + let user_ids: Vec = self.recognized_users.iter().map(|u| u.0).collect(); + Self::do_process_proposals(session, data, &user_ids) } fn do_process_proposals( session: &mut DaveSession, data: &[u8], - connected_users: &HashSet, + user_ids: &[u64], ) -> AnyResult>> { let op_type = match data[0] { 0 => ProposalsOperationType::APPEND, @@ -202,9 +223,8 @@ impl DaveHandler { raw => return Err(map_boxed_err(format!("Unknown DAVE proposals op: {raw}"))), }; - let user_ids: Vec = connected_users.iter().map(|u| u.0).collect(); let result = session - .process_proposals(op_type, &data[1..], Some(&user_ids)) + .process_proposals(op_type, &data[1..], Some(user_ids)) .map_err(map_boxed_err)?; if let Some(cw) = result { diff --git a/src/gateway/session/handler.rs b/src/gateway/session/handler.rs index f8567c89..1435ee38 100644 --- a/src/gateway/session/handler.rs +++ b/src/gateway/session/handler.rs @@ -8,7 +8,7 @@ use std::{ }; use serde_json::Value; -use tokio::sync::{Mutex, mpsc::UnboundedSender}; +use tokio::sync::mpsc::UnboundedSender; use tokio_tungstenite::tungstenite::protocol::Message; use tokio_util::sync::CancellationToken; use tracing::{debug, error, info, trace, warn}; @@ -42,7 +42,7 @@ pub struct SessionState<'a> { heartbeat: HeartbeatTracker, heartbeat_handle: Option>, conn_token: CancellationToken, - speaking_tx: UnboundedSender, + speaking_tx: Option>, session_key: Option<[u8; 32]>, speak_task: Option>, persistent_state: Arc>, @@ -50,21 +50,27 @@ pub struct SessionState<'a> { } impl<'a> SessionState<'a> { - pub fn new( + pub async fn new_v8( gateway: &'a VoiceGateway, tx: UnboundedSender, seq_ack: Arc, conn_token: CancellationToken, - speaking_tx: UnboundedSender, persistent_state: Arc>, backoff: &'a mut Backoff, ) -> Result { let mut users = HashSet::new(); users.insert(gateway.user_id); - let udp = std::net::UdpSocket::bind("0.0.0.0:0")?; - udp.set_nonblocking(true)?; - let udp_socket = Arc::new(tokio::net::UdpSocket::from_std(udp)?); + let mut socket_guard = gateway.udp_socket.lock().await; + let udp_socket = if let Some(existing) = &*socket_guard { + existing.clone() + } else { + let udp = std::net::UdpSocket::bind("0.0.0.0:0")?; + udp.set_nonblocking(true)?; + let socket = Arc::new(tokio::net::UdpSocket::from_std(udp)?); + *socket_guard = Some(socket.clone()); + socket + }; Ok(Self { gateway, @@ -75,14 +81,11 @@ impl<'a> SessionState<'a> { selected_mode: DEFAULT_VOICE_MODE.to_string(), connected_users: users, udp_socket, - dave: Arc::new(Mutex::new(DaveHandler::new( - gateway.user_id, - gateway.channel_id, - ))), + dave: gateway.dave.clone(), heartbeat: HeartbeatTracker::new(), heartbeat_handle: None, conn_token, - speaking_tx, + speaking_tx: None, session_key: None, speak_task: None, persistent_state, @@ -90,6 +93,10 @@ impl<'a> SessionState<'a> { }) } + pub fn set_speaking_tx(&mut self, tx: UnboundedSender) { + self.speaking_tx = Some(tx); + } + pub fn ssrc(&self) -> u32 { self.ssrc } @@ -106,10 +113,8 @@ impl<'a> SessionState<'a> { } }; - if let Ok(v) = serde_json::from_str::(&text) - && let Some(seq) = v["seq"].as_i64() - { - self.seq_ack.store(seq, Ordering::Relaxed); + if let Some(seq) = msg.seq { + self.seq_ack.store(seq as i64, Ordering::Relaxed); } let op = VoiceOp::from_raw(msg.op); @@ -124,20 +129,42 @@ impl<'a> SessionState<'a> { Some(VoiceOp::SessionDescription) => self.handle_session_description(msg.d).await, Some(VoiceOp::HeartbeatAck) => self.handle_heartbeat_ack(msg.d), Some(VoiceOp::Resumed) => self.handle_resumed().await, - Some(VoiceOp::ClientConnect) => self.handle_user_connect(msg.d), - Some(VoiceOp::ClientDisconnect) => self.handle_user_disconnect(msg.d), + Some(VoiceOp::ClientConnect) => self.handle_user_connect(msg.d).await, + Some(VoiceOp::ClientDisconnect) => self.handle_user_disconnect(msg.d).await, Some( VoiceOp::Speaking | VoiceOp::Video | VoiceOp::Codecs - | VoiceOp::MediaSinkWants - | VoiceOp::VoiceBackendVersion | VoiceOp::VoiceFlags - | VoiceOp::VoicePlatform, + | VoiceOp::VoicePlatform + | VoiceOp::DaveTransitionReady, ) => None, // Ignore informational events + Some(VoiceOp::VoiceBackendVersion) => { + info!( + "[{}] Voice Backend Version: {:?}", + self.gateway.guild_id, msg.d + ); + None + } + Some(VoiceOp::MediaSinkWants) => { + debug!("[{}] Media Sink Wants: {:?}", self.gateway.guild_id, msg.d); + None + } Some(VoiceOp::DavePrepareTransition) => self.handle_prepare_transition(msg.d).await, Some(VoiceOp::DaveExecuteTransition) => self.handle_execute_transition(msg.d).await, Some(VoiceOp::DavePrepareEpoch) => self.handle_prepare_epoch(msg.d).await, + Some(VoiceOp::DaveMlsAnnounceCommitTransition) => { + self.handle_mls_commit_transition(msg.d).await + } + Some(VoiceOp::DaveMlsInvalidCommitWelcome) => { + warn!( + "[{}] DAVE MLS Invalid Commit Welcome received, resetting session", + self.gateway.guild_id + ); + let mut dave = self.dave.lock().await; + self.reset_dave_session(&mut dave, 0).await; + None + } Some(_) => None, // Ignore other ops None => { warn!( @@ -167,7 +194,7 @@ impl<'a> SessionState<'a> { let mut dave = self.dave.lock().await; match op { 25 => { - if let Ok(resps) = dave.process_external_sender(payload, &self.connected_users) { + if let Ok(resps) = dave.process_external_sender(payload) { for resp in resps { self.send_binary(28, &resp); } @@ -219,7 +246,7 @@ impl<'a> SessionState<'a> { dave: &mut DaveHandler, payload: &[u8], ) -> crate::common::types::AnyResult<()> { - if let Some(cw) = dave.process_proposals(payload, &self.connected_users)? { + if let Some(cw) = dave.process_proposals(payload)? { self.send_binary(28, &cw); } Ok(()) @@ -228,7 +255,9 @@ impl<'a> SessionState<'a> { async fn reset_dave_session(&self, dave: &mut DaveHandler, tid: u16) { dave.reset(); self.send_json(31, serde_json::json!({ "transition_id": tid })); - if let Ok(kp) = dave.setup_session(DAVE_INITIAL_VERSION) { + if let Ok(kp) = dave.setup_session(DAVE_INITIAL_VERSION) + && !kp.is_empty() + { self.send_binary(26, &kp); } } @@ -238,6 +267,11 @@ impl<'a> SessionState<'a> { if let Some(h) = self.heartbeat_handle.take() { h.abort(); + warn!( + "[{}] Received unexpected mid-session HELLO. Forcing re-identify.", + self.gateway.guild_id + ); + return Some(SessionOutcome::Identify); } trace!( "[{}] Heartbeat interval: {interval}ms", @@ -280,6 +314,22 @@ impl<'a> SessionState<'a> { state.ssrc = self.ssrc; } + if self.gateway.channel_id.0 > 0 { + let protocol_version = d["dave_protocol_version"] + .as_u64() + .unwrap_or(DAVE_INITIAL_VERSION as u64) as u16; + + let mut dave = self.dave.lock().await; + if protocol_version > 0 { + dave.set_protocol_version(protocol_version); + if let Ok(kp) = dave.setup_session(protocol_version) { + self.send_binary(26, &kp); + } + } else { + dave.reset(); + } + } + match discover_ip(&self.udp_socket, addr, self.ssrc).await { Ok((my_ip, my_port)) => { let rtc_connection_id = Uuid::new_v4().to_string(); @@ -366,6 +416,10 @@ impl<'a> SessionState<'a> { } self.launch_speak_loop(addr, key).await; + self.send_json( + 12, + serde_json::json!({"audio_ssrc": self.ssrc, "video_ssrc": 0, "rtx_ssrc": 0}), + ); self.send_json( 5, serde_json::json!({"speaking": 0, "delay": 0, "ssrc": self.ssrc}), @@ -373,10 +427,24 @@ impl<'a> SessionState<'a> { } if self.gateway.channel_id.0 > 0 { + let protocol_version = d["dave_protocol_version"] + .as_u64() + .unwrap_or(DAVE_INITIAL_VERSION as u64) as u16; + let mls_group_id = d["mls_group_id"].as_u64().unwrap_or(0); + let mut dave = self.dave.lock().await; - if let Ok(kp) = dave.setup_session(DAVE_INITIAL_VERSION) { - self.send_binary(26, &kp); + if protocol_version > 0 { + dave.set_protocol_version(protocol_version); + if let Ok(kp) = dave.setup_session(protocol_version) { + self.send_binary(26, &kp); + } + } else { + dave.reset(); } + debug!( + "DAVE setup context: protocol_version={}, mls_group_id={}", + protocol_version, mls_group_id + ); } self.backoff.reset(); @@ -400,6 +468,10 @@ impl<'a> SessionState<'a> { self.ssrc = ssrc; self.launch_speak_loop(addr, key).await; + self.send_json( + 12, + serde_json::json!({"audio_ssrc": self.ssrc, "video_ssrc": 0, "rtx_ssrc": 0}), + ); self.send_json( 5, serde_json::json!({"speaking": 0, "delay": 0, "ssrc": self.ssrc}), @@ -425,20 +497,26 @@ impl<'a> SessionState<'a> { None } - fn handle_user_connect(&mut self, d: Value) -> Option { + async fn handle_user_connect(&mut self, d: Value) -> Option { if let Some(ids) = d["user_ids"].as_array() { + let mut uids = Vec::new(); for id in ids { if let Some(uid) = id.as_str().and_then(|s| s.parse::().ok()) { self.connected_users.insert(UserId(uid)); + uids.push(uid); } } + if !uids.is_empty() { + self.dave.lock().await.add_users(&uids); + } } None } - fn handle_user_disconnect(&mut self, d: Value) -> Option { + async fn handle_user_disconnect(&mut self, d: Value) -> Option { if let Some(uid) = d["user_id"].as_str().and_then(|s| s.parse::().ok()) { self.connected_users.remove(&UserId(uid)); + self.dave.lock().await.remove_user(uid); } None } @@ -446,6 +524,12 @@ impl<'a> SessionState<'a> { async fn handle_prepare_transition(&mut self, d: Value) -> Option { let tid = d["transition_id"].as_u64().unwrap_or(0) as u16; let ver = d["protocol_version"].as_u64().unwrap_or(0) as u16; + + debug!( + "[{}] DAVE Prepare Transition: id={}, version={}", + self.gateway.guild_id, tid, ver + ); + if self.dave.lock().await.prepare_transition(tid, ver) { self.send_json(23, serde_json::json!({ "transition_id": tid })); } @@ -454,6 +538,10 @@ impl<'a> SessionState<'a> { async fn handle_execute_transition(&mut self, d: Value) -> Option { let tid = d["transition_id"].as_u64().unwrap_or(0) as u16; + debug!( + "[{}] DAVE Execute Transition: id={}", + self.gateway.guild_id, tid + ); self.dave.lock().await.execute_transition(tid); None } @@ -461,7 +549,31 @@ impl<'a> SessionState<'a> { async fn handle_prepare_epoch(&mut self, d: Value) -> Option { let epoch = d["epoch"].as_u64().unwrap_or(0); let ver = d["protocol_version"].as_u64().unwrap_or(0) as u16; - self.dave.lock().await.prepare_epoch(epoch, ver); + debug!( + "[{}] DAVE Prepare Epoch: epoch={}, version={}", + self.gateway.guild_id, epoch, ver + ); + if let Some(kp) = self.dave.lock().await.prepare_epoch(epoch, ver) + && !kp.is_empty() + { + self.send_binary(26, &kp); + } + None + } + + async fn handle_mls_commit_transition(&mut self, d: Value) -> Option { + let tid = d["transition_id"].as_u64().unwrap_or(0) as u16; + debug!( + "[{}] DAVE MLS Announce Commit Transition: tid={}", + self.gateway.guild_id, tid + ); + let ver = d["protocol_version"].as_u64().map(|v| v as u16); + if let Some(v) = ver { + let mut dave = self.dave.lock().await; + if dave.prepare_transition(tid, v) && tid != 0 { + self.send_json(23, serde_json::json!({ "transition_id": tid })); + } + } None } @@ -484,7 +596,8 @@ impl<'a> SessionState<'a> { frames_sent: self.gateway.frames_sent.clone(), frames_nulled: self.gateway.frames_nulled.clone(), cancel_token: self.conn_token.clone(), - speaking_tx: self.speaking_tx.clone(), + speaking_tx: self.speaking_tx.clone().unwrap(), // Internal error if None + persistent_state: self.persistent_state.clone(), }; self.speak_task = Some(tokio::spawn(async move { @@ -495,7 +608,7 @@ impl<'a> SessionState<'a> { } fn send_json(&self, op: u8, d: Value) { - let msg = VoiceGatewayMessage { op, d }; + let msg = VoiceGatewayMessage { op, seq: None, d }; if let Ok(json) = serde_json::to_string(&msg) { let _ = self.tx.send(Message::Text(json.into())); } diff --git a/src/gateway/session/heartbeat.rs b/src/gateway/session/heartbeat.rs index aa8587c3..387d3d54 100644 --- a/src/gateway/session/heartbeat.rs +++ b/src/gateway/session/heartbeat.rs @@ -74,6 +74,7 @@ impl HeartbeatTracker { let hb = VoiceGatewayMessage { op: OP_HEARTBEAT, + seq: None, d: serde_json::json!({ "t": nonce, "seq_ack": seq_ack.load(Ordering::Relaxed) }), }; diff --git a/src/gateway/session/mod.rs b/src/gateway/session/mod.rs index 2d590094..7ef2e383 100644 --- a/src/gateway/session/mod.rs +++ b/src/gateway/session/mod.rs @@ -46,6 +46,8 @@ pub struct VoiceGateway { event_tx: Option>, pub frames_sent: Arc, pub frames_nulled: Arc, + pub udp_socket: Shared>>, + pub dave: Shared, outer_token: CancellationToken, } @@ -85,6 +87,11 @@ impl VoiceGateway { event_tx: config.event_tx, frames_sent: config.frames_sent, frames_nulled: config.frames_nulled, + udp_socket: Arc::new(tokio::sync::Mutex::new(None)), + dave: Arc::new(tokio::sync::Mutex::new(crate::gateway::DaveHandler::new( + config.user_id, + config.channel_id, + ))), outer_token: CancellationToken::new(), } } @@ -92,7 +99,7 @@ impl VoiceGateway { pub async fn run(self) -> AnyResult<()> { let mut backoff = Backoff::new(); let mut is_resume = false; - let seq_ack = Arc::new(AtomicI64::new(-1)); + let seq_ack = Arc::new(AtomicI64::new(0)); let persistent_state = Arc::new(tokio::sync::Mutex::new(PersistentSessionState::default())); while !self.outer_token.is_cancelled() { @@ -130,13 +137,14 @@ impl VoiceGateway { return Ok(()); } is_resume = false; - seq_ack.store(-1, Ordering::Relaxed); + seq_ack.store(0, Ordering::Relaxed); // Clear persistent state on identify to avoid using stale keys/addr { let mut state = persistent_state.lock().await; state.udp_addr = None; state.session_key = None; } + *self.udp_socket.lock().await = None; let delay = std::time::Duration::from_millis(RECONNECT_DELAY_FRESH_MS); debug!( "[{}] Session invalid; identifying fresh in {:?}", @@ -194,29 +202,6 @@ impl VoiceGateway { .map_err(map_boxed_err)?; let (mut write, mut read) = ws_stream.split(); - let handshake = if is_resume { - trace!( - "[{}] Sending voice RESUME: {:?}", - self.guild_id, self.session_id - ); - self.resume_message(seq_ack.load(Ordering::Relaxed)) - } else { - trace!( - "[{}] Sending voice IDENTIFY: {:?}", - self.guild_id, self.session_id - ); - self.identify_message() - }; - - write - .send(Message::Text( - serde_json::to_string(&handshake) - .map_err(map_boxed_err)? - .into(), - )) - .await - .map_err(map_boxed_err)?; - let conn_token = CancellationToken::new(); let (ws_tx, mut ws_rx) = unbounded_channel::(); @@ -235,23 +220,67 @@ impl VoiceGateway { } }); - let (speaking_tx, mut speaking_rx) = unbounded_channel::(); - - let mut state = handler::SessionState::new( + let mut state = handler::SessionState::new_v8( self, ws_tx.clone(), seq_ack.clone(), conn_token.clone(), - speaking_tx, persistent_state, backoff, ) + .await .map_err(|e| { warn!("[{}] Init session failed: {e}", self.guild_id); conn_token.cancel(); e })?; + // V8 Handshake: Wait for Op 8 HELLO before identifying + let msg = read.next().await; + match msg { + Some(Ok(m)) => { + if let Some(out) = self.handle_ws_message(&mut state, m).await { + conn_token.cancel(); + return Ok(out); + } + } + Some(Err(e)) => { + warn!("[{}] Initial read error: {e}", self.guild_id); + conn_token.cancel(); + return Ok(SessionOutcome::Reconnect); + } + None => { + warn!("[{}] WS closed before HELLO", self.guild_id); + conn_token.cancel(); + return Ok(SessionOutcome::Reconnect); + } + } + + let handshake = if is_resume { + trace!( + "[{}] Sending voice RESUME: {:?}", + self.guild_id, self.session_id + ); + self.resume_message(seq_ack.load(Ordering::Relaxed)) + } else { + trace!( + "[{}] Sending voice IDENTIFY: {:?}", + self.guild_id, self.session_id + ); + self.identify_message() + }; + + ws_tx + .send(Message::Text( + serde_json::to_string(&handshake) + .map_err(map_boxed_err)? + .into(), + )) + .map_err(|_| map_boxed_err("failed to send handshake"))?; + + let (speaking_tx, mut speaking_rx) = unbounded_channel::(); + state.set_speaking_tx(speaking_tx); + let outcome = loop { tokio::select! { biased; @@ -316,6 +345,7 @@ impl VoiceGateway { ) { let msg = VoiceGatewayMessage { op: 5, + seq: None, d: serde_json::json!({ "speaking": if is_speaking { 1u8 } else { 0u8 }, "delay": 0, @@ -361,6 +391,7 @@ impl VoiceGateway { fn identify_message(&self) -> VoiceGatewayMessage { VoiceGatewayMessage { op: 0, + seq: None, d: serde_json::json!({ "server_id": self.guild_id.to_string(), "user_id": self.user_id.0.to_string(), @@ -375,6 +406,7 @@ impl VoiceGateway { fn resume_message(&self, seq_ack: i64) -> VoiceGatewayMessage { VoiceGatewayMessage { op: 7, + seq: None, d: serde_json::json!({ "server_id": self.guild_id.to_string(), "session_id": self.session_id, diff --git a/src/gateway/session/types.rs b/src/gateway/session/types.rs index b7bd4f22..3b1013b6 100644 --- a/src/gateway/session/types.rs +++ b/src/gateway/session/types.rs @@ -6,6 +6,8 @@ use crate::common::types::AnyError; #[derive(Serialize, Deserialize, Debug)] pub struct VoiceGatewayMessage { pub op: u8, + #[serde(rename = "seq", skip_serializing_if = "Option::is_none")] + pub seq: Option, pub d: Value, } @@ -24,6 +26,7 @@ pub struct PersistentSessionState { pub ssrc: u32, pub udp_addr: Option, pub session_key: Option<[u8; 32]>, + pub rtp_state: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -89,7 +92,7 @@ pub fn classify_close(code: u16) -> SessionOutcome { // Fatal/Shutdown codes CloseAction::AuthenticationFailed => SessionOutcome::Shutdown, - CloseAction::InvalidSession => SessionOutcome::Shutdown, + CloseAction::InvalidSession => SessionOutcome::Identify, CloseAction::ServerNotFound => SessionOutcome::Shutdown, CloseAction::Disconnected => SessionOutcome::Shutdown, CloseAction::RateLimited => SessionOutcome::Shutdown, @@ -118,12 +121,12 @@ pub enum VoiceOp { ClientDisconnect = 13, Codecs = 14, MediaSinkWants = 15, - VoiceBackendVersion = 16, VoiceFlags = 18, VoicePlatform = 20, DavePrepareTransition = 21, DaveExecuteTransition = 22, DaveTransitionReady = 23, + VoiceBackendVersion = 16, DavePrepareEpoch = 24, DaveMlsExternalSender = 25, DaveMlsKeyPackage = 26, @@ -132,6 +135,7 @@ pub enum VoiceOp { DaveMlsAnnounceCommitTransition = 29, DaveMlsWelcome = 30, DaveMlsInvalidCommitWelcome = 31, + NoRoute = 32, } impl VoiceOp { @@ -152,12 +156,10 @@ impl VoiceOp { 13 => Self::ClientDisconnect, 14 => Self::Codecs, 15 => Self::MediaSinkWants, - 16 => Self::VoiceBackendVersion, 18 => Self::VoiceFlags, 20 => Self::VoicePlatform, 21 => Self::DavePrepareTransition, 22 => Self::DaveExecuteTransition, - 23 => Self::DaveTransitionReady, 24 => Self::DavePrepareEpoch, 25 => Self::DaveMlsExternalSender, 26 => Self::DaveMlsKeyPackage, @@ -166,6 +168,9 @@ impl VoiceOp { 29 => Self::DaveMlsAnnounceCommitTransition, 30 => Self::DaveMlsWelcome, 31 => Self::DaveMlsInvalidCommitWelcome, + 23 => Self::DaveTransitionReady, + 16 => Self::VoiceBackendVersion, + 32 => Self::NoRoute, _ => return None, }) } diff --git a/src/gateway/session/voice.rs b/src/gateway/session/voice.rs index 0f62b4d1..3139679a 100644 --- a/src/gateway/session/voice.rs +++ b/src/gateway/session/voice.rs @@ -87,18 +87,25 @@ pub struct SpeakConfig { pub frames_nulled: Arc, pub cancel_token: CancellationToken, pub speaking_tx: UnboundedSender, + pub persistent_state: Arc>, } pub async fn speak_loop(config: SpeakConfig) -> AnyResult<()> { - let mut encoder = Encoder::new().map_err(map_boxed_err)?; + let rtp_state = { + let state = config.persistent_state.lock().await; + state.rtp_state + }; + let transport = VoiceTransport::new( config.socket.clone(), config.addr, config.ssrc, config.key, &config.mode, + rtp_state, )?; + let mut encoder = Encoder::new().map_err(map_boxed_err)?; let mut session = VoiceSession::new(config, transport); session.run(&mut encoder).await } @@ -135,8 +142,21 @@ impl VoiceSession { while !self.config.cancel_token.is_cancelled() { interval.tick().await; self.tick(encoder, &mut pcm, &mut opus, &mut ts_pcm).await?; + + if self + .config + .frames_sent + .load(Ordering::Relaxed) + .is_multiple_of(100) + { + let mut state = self.config.persistent_state.lock().await; + state.rtp_state = Some(self.transport.rtp); + } } + let mut state = self.config.persistent_state.lock().await; + state.rtp_state = Some(self.transport.rtp); + Ok(()) } diff --git a/src/gateway/udp_link.rs b/src/gateway/udp_link.rs index 661d935e..ef1e36fe 100644 --- a/src/gateway/udp_link.rs +++ b/src/gateway/udp_link.rs @@ -1,3 +1,4 @@ +use serde::{Deserialize, Serialize}; use std::{net::SocketAddr, sync::Arc}; use davey::{AeadInPlace, Aes256Gcm, KeyInit}; @@ -18,21 +19,22 @@ use crate::{ pub struct VoiceTransport { socket: Arc, address: SocketAddr, - ssrc: u32, - crypto: CryptoBackend, - rtp: RtpState, - buffer: Vec, + pub ssrc: u32, + pub crypto: CryptoBackend, + pub rtp: RtpState, + pub buffer: Vec, } -enum CryptoBackend { +pub enum CryptoBackend { XSalsa20Poly1305(Box), Aes256Gcm(Box), } -struct RtpState { - sequence: u16, - timestamp: u32, - nonce: u32, +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +pub struct RtpState { + pub sequence: u16, + pub timestamp: u32, + pub nonce: u32, } impl VoiceTransport { @@ -42,6 +44,7 @@ impl VoiceTransport { ssrc: u32, secret_key: [u8; 32], mode: &str, + rtp_state: Option, ) -> AnyResult { let crypto = match mode { "aead_aes256_gcm_rtpsize" => { @@ -57,7 +60,7 @@ impl VoiceTransport { address, ssrc, crypto, - rtp: RtpState::randomize(), + rtp: rtp_state.unwrap_or_else(RtpState::randomize), buffer: Vec::with_capacity(UDP_PACKET_BUF_CAPACITY), }) } diff --git a/src/player/manager/start.rs b/src/player/manager/start.rs index ced49b4d..f1a24e05 100644 --- a/src/player/manager/start.rs +++ b/src/player/manager/start.rs @@ -72,15 +72,10 @@ pub async fn start_playback(player: &mut PlayerContext, config: PlaybackStartCon ) .await { - Ok(Some(t)) => t, - Ok(None) => { - error!("Failed to resolve track: {}", identifier); - send_load_failed( - player, - &config.session, - format!("Failed to resolve: {identifier}"), - ) - .await; + Ok(Ok(t)) => t, + Ok(Err(e)) => { + error!("Failed to resolve track: {} (Error: {})", identifier, e); + send_load_failed(player, &config.session, e).await; return; } Err(_) => { @@ -103,6 +98,8 @@ pub async fn start_playback(player: &mut PlayerContext, config: PlaybackStartCon let (frame_rx, cmd_tx, err_rx) = playable.start_decoding(player.config.clone()); let (handle, audio_state, vol, pos) = TrackHandle::new(cmd_tx, player.tape_stop.clone()); + handle.set_volume(player.volume as f32 / 100.0); + { let engine = player.engine.lock().await; let mut mixer = engine.mixer.lock().await; diff --git a/src/sources/audiomack/manager.rs b/src/sources/audiomack/manager.rs index fd8bf7d0..3b275a48 100644 --- a/src/sources/audiomack/manager.rs +++ b/src/sources/audiomack/manager.rs @@ -541,10 +541,20 @@ impl SourcePlugin for AudiomackSource { } } + let local_addr = routeplanner.and_then(|rp| rp.get_address()); + + let stream_url = super::track::fetch_stream_url(&self.client, &track_id).await; + if stream_url.is_none() { + warn!( + "Audiomack: no stream URL for track {}, falling back to mirrors", + track_id + ); + return None; + } + Some(Box::new(AudiomackTrack { - client: self.client.clone(), - identifier: track_id, - local_addr: routeplanner.and_then(|rp| rp.get_address()), + stream_url: stream_url.unwrap(), + local_addr, })) } diff --git a/src/sources/audiomack/track.rs b/src/sources/audiomack/track.rs index a6dd1d9f..b83f41dc 100644 --- a/src/sources/audiomack/track.rs +++ b/src/sources/audiomack/track.rs @@ -2,77 +2,29 @@ use std::{collections::BTreeMap, net::IpAddr, sync::Arc}; use rand::{Rng, distributions::Alphanumeric, thread_rng}; -use crate::{ - audio::{AudioFrame, processor::DecoderCommand}, - sources::{ - audiomack::utils::build_auth_header, - http::HttpTrack, - plugin::{DecoderOutput, PlayableTrack}, - }, +use crate::sources::{ + audiomack::utils::build_auth_header, + http::HttpTrack, + plugin::{DecoderOutput, PlayableTrack}, }; pub struct AudiomackTrack { - pub client: Arc, - pub identifier: String, + pub stream_url: String, pub local_addr: Option, } impl PlayableTrack for AudiomackTrack { fn start_decoding(&self, config: crate::config::player::PlayerConfig) -> DecoderOutput { - let (tx, rx) = flume::bounded::((config.buffer_duration_ms / 20) as usize); - let (cmd_tx, cmd_rx) = flume::unbounded::(); - let (err_tx, err_rx) = flume::bounded::(1); - - let identifier = self.identifier.clone(); - let client = self.client.clone(); - let local_addr = self.local_addr; - - let handle = tokio::runtime::Handle::current(); - std::thread::spawn(move || { - let _guard = handle.enter(); - handle.block_on(async move { - if let Some(url) = fetch_stream_url(&client, &identifier).await { - let http_track = HttpTrack { - url, - local_addr, - proxy: None, - }; - let (inner_rx, inner_cmd_tx, inner_err_rx) = - http_track.start_decoding(config.clone()); - - // Proxy commands - let cmd_tx_clone = inner_cmd_tx.clone(); - std::thread::spawn(move || { - while let Ok(cmd) = cmd_rx.recv() { - let _ = cmd_tx_clone.send(cmd); - } - }); - - // Proxy errors - let err_tx_clone = err_tx.clone(); - std::thread::spawn(move || { - while let Ok(err) = inner_err_rx.recv() { - let _ = err_tx_clone.send(err); - } - }); - - // Proxy samples - while let Ok(sample) = inner_rx.recv() { - if tx.send(sample).is_err() { - break; - } - } - } else { - let _ = err_tx.send("Failed to fetch Audiomack stream URL".to_owned()); - } - }); - }); - - (rx, cmd_tx, err_rx) + let http_track = HttpTrack { + url: self.stream_url.clone(), + local_addr: self.local_addr, + proxy: None, + }; + http_track.start_decoding(config) } } -async fn fetch_stream_url(client: &Arc, identifier: &str) -> Option { +pub async fn fetch_stream_url(client: &Arc, identifier: &str) -> Option { let nonce = generate_nonce(); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -80,7 +32,7 @@ async fn fetch_stream_url(client: &Arc, identifier: &str) -> Op .as_secs() .to_string(); - // Strategy 1: POST /music/{id}/play (preferred for web) + // Strategy 1: POST /music/{id}/play let post_url = format!("https://api.audiomack.com/v1/music/{identifier}/play"); let mut body = BTreeMap::new(); body.insert("environment".to_owned(), "desktop-web".to_owned()); @@ -99,7 +51,7 @@ async fn fetch_stream_url(client: &Arc, identifier: &str) -> Op return Some(url); } - // Strategy 2: GET /music/play/{id} (legacy/fallback) + // Strategy 2: GET /music/play/{id} (fallback) let get_url = format!("https://api.audiomack.com/v1/music/play/{identifier}"); let mut query = BTreeMap::new(); query.insert("environment".to_owned(), "desktop-web".to_owned()); diff --git a/src/sources/deezer/mod.rs b/src/sources/deezer/mod.rs index 9d497cea..fa4bdeb3 100644 --- a/src/sources/deezer/mod.rs +++ b/src/sources/deezer/mod.rs @@ -171,10 +171,17 @@ impl SourcePlugin for DeezerSource { identifier.to_owned() }; + let resolved = + track::verify_track_resolvable(&self.client, &track_id, &self.token_tracker).await; + + if resolved.is_none() { + tracing::warn!("Deezer: no stream URL for track {track_id}, falling back to mirrors"); + return None; + } + Some(Box::new(DeezerTrack { client: self.client.clone(), track_id, - arl_index: 0, // get_token will rotate token_tracker: self.token_tracker.clone(), master_key: self .config diff --git a/src/sources/deezer/track.rs b/src/sources/deezer/track.rs index 2ef0f2be..db88a40f 100644 --- a/src/sources/deezer/track.rs +++ b/src/sources/deezer/track.rs @@ -17,7 +17,6 @@ use crate::{ pub struct DeezerTrack { pub client: Arc, pub track_id: String, - pub arl_index: usize, pub token_tracker: Arc, pub master_key: String, pub local_addr: Option, @@ -240,3 +239,90 @@ impl PlayableTrack for DeezerTrack { (rx, cmd_tx, err_rx) } } + +pub(super) async fn verify_track_resolvable( + client: &Arc, + track_id: &str, + token_tracker: &crate::sources::deezer::token::DeezerTokenTracker, +) -> Option { + let tokens = token_tracker.get_token().await?; + + let song_url = format!( + "https://www.deezer.com/ajax/gw-light.php?method=song.getData&input=3&api_version=1.0&api_token={}", + tokens.api_token + ); + let json: serde_json::Value = client + .post(&song_url) + .header( + "Cookie", + format!( + "sid={}; dzr_uniq_id={}", + tokens.session_id, tokens.dzr_uniq_id + ), + ) + .json(&serde_json::json!({ "sng_id": track_id })) + .send() + .await + .ok()? + .json() + .await + .ok()?; + + if json + .get("error") + .and_then(|v| v.as_array()) + .is_some_and(|e| !e.is_empty()) + { + token_tracker.invalidate_token(tokens.arl_index).await; + return None; + } + + let track_token = json + .get("results") + .and_then(|r| r.get("TRACK_TOKEN")) + .and_then(|v| v.as_str())? + .to_owned(); + + let media_body = serde_json::json!({ + "license_token": tokens.license_token, + "media": [{ + "type": "FULL", + "formats": [ + { "cipher": "BF_CBC_STRIPE", "format": "MP3_128" } + ] + }], + "track_tokens": [track_token] + }); + + let media_json: serde_json::Value = client + .post("https://media.deezer.com/v1/get_url") + .json(&media_body) + .send() + .await + .ok()? + .json() + .await + .ok()?; + + if media_json + .get("data") + .and_then(|d| d.get(0)) + .and_then(|d| d.get("errors")) + .and_then(|e| e.as_array()) + .is_some_and(|e| !e.is_empty()) + { + token_tracker.invalidate_token(tokens.arl_index).await; + return None; + } + + media_json + .get("data") + .and_then(|d| d.get(0)) + .and_then(|d| d.get("media")) + .and_then(|m| m.get(0)) + .and_then(|m| m.get("sources")) + .and_then(|s| s.get(0)) + .and_then(|s| s.get("url")) + .and_then(|u| u.as_str()) + .map(|s| s.to_owned()) +} diff --git a/src/sources/gaana/manager.rs b/src/sources/gaana/manager.rs index 87674967..45c8f5c6 100644 --- a/src/sources/gaana/manager.rs +++ b/src/sources/gaana/manager.rs @@ -579,6 +579,15 @@ impl SourcePlugin for GaanaSource { identifier.to_owned() }; + let stream_url = + super::track::fetch_stream_url_internal(&self.client, &track_id, &self.stream_quality) + .await; + + if stream_url.is_none() { + warn!("Gaana: no stream URL for track {track_id}, falling back to mirrors"); + return None; + } + Some(Box::new(GaanaTrack { client: self.client.clone(), track_id, diff --git a/src/sources/gaana/track.rs b/src/sources/gaana/track.rs index aa313871..cac6277f 100644 --- a/src/sources/gaana/track.rs +++ b/src/sources/gaana/track.rs @@ -98,7 +98,7 @@ impl PlayableTrack for GaanaTrack { } } -async fn fetch_stream_url_internal( +pub(super) async fn fetch_stream_url_internal( client: &Arc, track_id: &str, quality: &str, diff --git a/src/sources/manager/best_match.rs b/src/sources/manager/best_match.rs new file mode 100644 index 00000000..15e59d80 --- /dev/null +++ b/src/sources/manager/best_match.rs @@ -0,0 +1,358 @@ +use std::sync::Arc; + +use futures::stream::{FuturesOrdered, FuturesUnordered, StreamExt}; + +use crate::sources::{manager::SourceManager, plugin::BoxedTrack}; + +pub struct MirrorResult { + pub track: BoxedTrack, + pub score: f64, + pub provider: String, +} + +fn normalize(s: &str) -> String { + let lower = s.to_lowercase(); + + let mut stripped = String::with_capacity(lower.len()); + let mut depth: usize = 0; + for ch in lower.chars() { + match ch { + '(' | '[' => depth += 1, + ')' | ']' => depth = depth.saturating_sub(1), + _ if depth == 0 => stripped.push(ch), + _ => {} + } + } + + let stripped = stripped + .replace("feat.", " ") + .replace("feat ", " ") + .replace("ft.", " ") + .replace("ft ", " "); + + let clean: String = stripped + .chars() + .map(|c| { + if c.is_alphanumeric() || c == ' ' { + c + } else { + ' ' + } + }) + .collect(); + + clean.split_whitespace().collect::>().join(" ") +} + +fn levenshtein(a: &str, b: &str) -> usize { + let a: Vec = a.chars().collect(); + let b: Vec = b.chars().collect(); + let (m, n) = (a.len(), b.len()); + let mut prev: Vec = (0..=n).collect(); + let mut curr = vec![0usize; n + 1]; + for i in 1..=m { + curr[0] = i; + for j in 1..=n { + let cost = usize::from(a[i - 1] != b[j - 1]); + curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost); + } + std::mem::swap(&mut prev, &mut curr); + } + prev[n] +} + +fn string_similarity(a: &str, b: &str) -> f64 { + if a == b { + return 1.0; + } + if a.is_empty() || b.is_empty() { + return 0.0; + } + let na = normalize(a); + let nb = normalize(b); + if na == nb { + return 1.0; + } + if na.contains(&nb) || nb.contains(&na) { + let shorter = na.len().min(nb.len()) as f64; + let longer = na.len().max(nb.len()) as f64; + return 0.80 + (shorter / longer) * 0.15; + } + let max_len = na.len().max(nb.len()); + if max_len == 0 { + return 1.0; + } + 1.0 - levenshtein(&na, &nb) as f64 / max_len as f64 +} + +fn duration_similarity(d1: u64, d2: u64, tolerance_ms: u64) -> f64 { + if d1 == 0 || d2 == 0 { + return 0.5; + } + let diff = d1.abs_diff(d2); + if diff <= tolerance_ms { + 1.0 + } else { + (1.0 - diff as f64 / d1.max(d2) as f64).max(0.0) + } +} + +fn score_match( + orig_title: &str, + orig_author: &str, + orig_length: u64, + cand_title: &str, + cand_author: &str, + cand_length: u64, + cfg: &crate::config::server::BestMatchConfig, +) -> f64 { + let nt = normalize(orig_title); + let nc = normalize(cand_title); + + let title_score = if nt == nc { + 1.0 + } else if nc.starts_with(&nt) { + 0.95 + } else if nc.contains(&nt) || nt.contains(&nc) { + let shorter = nt.len().min(nc.len()) as f64; + let longer = nt.len().max(nc.len()) as f64; + 0.82 + (shorter / longer) * 0.10 + } else { + string_similarity(&nt, &nc) + }; + + title_score * cfg.weight_title + + string_similarity(orig_author, cand_author) * cfg.weight_artist + + duration_similarity(orig_length, cand_length, cfg.duration_tolerance_ms) + * cfg.weight_duration +} + +fn fmt_ms(ms: u64) -> String { + let s = ms / 1_000; + format!("{}:{:02}", s / 60, s % 60) +} + +pub async fn resolve_scored( + manager: &SourceManager, + track_info: &crate::protocol::tracks::TrackInfo, + identifier: &str, + mirrors: &crate::config::server::MirrorsConfig, + routeplanner: Option>, +) -> Result { + let isrc = track_info.isrc.as_deref().unwrap_or(""); + let query = format!("{} {}", track_info.title, track_info.author); + let cfg = &mirrors.best_match; + + let original_source_name = manager + .sources + .iter() + .find(|s| s.can_handle(identifier)) + .map(|s| s.name().to_string()); + + let mut isrc_providers: Vec = Vec::new(); + let mut free_providers: Vec = Vec::new(); + let mut throttled_providers: Vec = Vec::new(); + + for provider in &mirrors.providers { + let is_isrc_provider = provider.contains("%ISRC%"); + + if is_isrc_provider && isrc.is_empty() { + tracing::debug!("Skipping mirror provider '{}': track has no ISRC", provider); + continue; + } + + let resolved = provider.replace("%ISRC%", isrc).replace("%QUERY%", &query); + + if let Some(src) = manager.sources.iter().find(|s| s.can_handle(&resolved)) { + if src.is_mirror() { + tracing::warn!( + "Skipping mirror provider '{}': '{}' is a Mirror-type source", + resolved, + src.name() + ); + continue; + } + if Some(src.name().to_string()) == original_source_name { + tracing::debug!( + "Skipping mirror provider '{}': would loop back to '{}'", + resolved, + src.name() + ); + continue; + } + } + + if is_isrc_provider { + isrc_providers.push(resolved); + } else if cfg + .throttled_prefixes + .iter() + .any(|p| resolved.starts_with(p.as_str())) + { + throttled_providers.push(resolved); + } else { + free_providers.push(resolved); + } + } + + if !isrc_providers.is_empty() { + let mut futs: FuturesOrdered<_> = isrc_providers + .iter() + .map(|p| search_provider(manager, track_info, p, routeplanner.clone(), cfg, true)) + .collect(); + + while let Some(result) = futs.next().await { + if let Some(mr) = result { + tracing::info!( + "[Mirror] ISRC match \"{}\" | {} | {} => {} | score: {:.3}", + track_info.title, + track_info.author, + fmt_ms(track_info.length), + mr.provider, + mr.score, + ); + return Ok(mr.track); + } + } + } + + let mut global_best: Option = None; + + if !free_providers.is_empty() { + let mut futs: FuturesUnordered<_> = free_providers + .iter() + .map(|p| search_provider(manager, track_info, p, routeplanner.clone(), cfg, false)) + .collect(); + + while let Some(result) = futs.next().await { + if let Some(mr) = result { + tracing::info!( + "[Mirror] \"{}\" | {} | {} => {} | score: {:.3}", + track_info.title, + track_info.author, + fmt_ms(track_info.length), + mr.provider, + mr.score, + ); + + if mr.score >= cfg.immediate_use { + return Ok(mr.track); + } + + if global_best.as_ref().is_none_or(|b| mr.score > b.score) { + global_best = Some(mr); + } + } + } + } + + for provider in &throttled_providers { + if let Some(mr) = search_provider( + manager, + track_info, + provider, + routeplanner.clone(), + cfg, + true, + ) + .await + { + tracing::info!( + "[Mirror] throttled match \"{}\" via {} (score {:.3})", + track_info.title, + mr.provider, + mr.score + ); + return Ok(mr.track); + } + } + + if let Some(best) = global_best { + tracing::info!( + "[Mirror] fallback match \"{}\" via {} (score {:.3})", + track_info.title, + best.provider, + best.score + ); + return Ok(best.track); + } + + tracing::warn!( + "[Mirror] no valid mirror found for \"{}\" | {}", + track_info.title, + track_info.author + ); + Err(format!( + "No mirror found for track: {} - {}", + track_info.title, track_info.author + )) +} + +async fn search_provider( + manager: &SourceManager, + original: &crate::protocol::tracks::TrackInfo, + resolved_provider: &str, + routeplanner: Option>, + cfg: &crate::config::server::BestMatchConfig, + trust_any: bool, +) -> Option { + use crate::protocol::tracks::LoadResult; + + let candidates: Vec = + match manager.load(resolved_provider, routeplanner.clone()).await { + LoadResult::Track(t) => vec![t.info], + LoadResult::Search(tracks) => tracks.into_iter().take(10).map(|t| t.info).collect(), + _ => return None, + }; + + if candidates.is_empty() { + return None; + } + + let mut scored: Vec<(f64, crate::protocol::tracks::TrackInfo)> = candidates + .into_iter() + .map(|info| { + let s = score_match( + &original.title, + &original.author, + original.length, + &info.title, + &info.author, + info.length, + cfg, + ); + (s, info) + }) + .collect(); + scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + + let top_score = scored[0].0; + + let (limit, threshold): (usize, f64) = if trust_any { + (scored.len(), 0.0) + } else if top_score >= cfg.immediate_use { + (1, cfg.immediate_use) + } else if top_score >= cfg.high_confidence { + (2, cfg.high_confidence) + } else { + (3, cfg.min_similarity) + }; + + for (score, info) in scored.into_iter().take(limit) { + if score < threshold { + break; + } + let id = info.uri.as_deref().unwrap_or(&info.identifier); + if let Some(track) = + super::resolver::resolve_nested_track(manager, id, routeplanner.clone()).await + { + return Some(MirrorResult { + track, + score, + provider: resolved_provider.to_string(), + }); + } + } + + None +} diff --git a/src/sources/manager/mod.rs b/src/sources/manager/mod.rs index 4d02a077..c7f7d8a6 100644 --- a/src/sources/manager/mod.rs +++ b/src/sources/manager/mod.rs @@ -5,6 +5,7 @@ use crate::{ sources::plugin::{BoxedSource, BoxedTrack}, }; +mod best_match; mod registration; mod resolver; @@ -79,12 +80,11 @@ impl SourceManager { None } - /// Resolves a playable track from track info, falling back to mirrors if necessary. pub async fn resolve_track( &self, track_info: &crate::protocol::tracks::TrackInfo, routeplanner: Option>, - ) -> Option { + ) -> Result { let identifier = track_info.uri.as_deref().unwrap_or(&track_info.identifier); for source in &self.sources { @@ -96,7 +96,7 @@ impl SourceManager { ); if let Some(track) = source.get_track(identifier, routeplanner.clone()).await { - return Some(track); + return Ok(track); } break; } @@ -113,8 +113,10 @@ impl SourceManager { .await; } - tracing::debug!("Failed to resolve playable track for: {}", identifier); - None + Err(format!( + "Failed to resolve playable track for: {}", + identifier + )) } /// Get names of all registered sources. diff --git a/src/sources/manager/resolver.rs b/src/sources/manager/resolver.rs index 5a770b1b..78100b4a 100644 --- a/src/sources/manager/resolver.rs +++ b/src/sources/manager/resolver.rs @@ -9,7 +9,18 @@ pub async fn resolve_with_mirrors( identifier: &str, mirrors: &crate::config::server::MirrorsConfig, routeplanner: Option>, -) -> Option { +) -> Result { + if mirrors.best_match.scoring { + return super::best_match::resolve_scored( + manager, + track_info, + identifier, + mirrors, + routeplanner, + ) + .await; + } + let isrc = track_info.isrc.as_deref().unwrap_or(""); let query = format!("{} - {}", track_info.title, track_info.author); @@ -63,24 +74,36 @@ pub async fn resolve_with_mirrors( }; if let Some(track) = res { - return Some(track); + return Ok(track); } } - None + tracing::warn!( + "[Mirror] no valid mirror found for track: {} - {}", + track_info.title, + track_info.author + ); + Err(format!( + "No mirror found for track: {} - {}", + track_info.title, track_info.author + )) } /// Helper to resolve a playable track from a source after a mirror redirect. -async fn resolve_nested_track( +pub async fn resolve_nested_track( manager: &SourceManager, identifier: &str, routeplanner: Option>, ) -> Option { for source in &manager.sources { - if source.can_handle(identifier) - && let Some(track) = source.get_track(identifier, routeplanner.clone()).await - { - return Some(track); + if source.can_handle(identifier) { + if let Some(track) = source.get_track(identifier, routeplanner.clone()).await { + return Some(track); + } + + if source.name() != "http" { + return None; + } } } None diff --git a/src/sources/yandexmusic/mod.rs b/src/sources/yandexmusic/mod.rs index 2e72ff05..6804a68f 100644 --- a/src/sources/yandexmusic/mod.rs +++ b/src/sources/yandexmusic/mod.rs @@ -537,6 +537,15 @@ impl SourcePlugin for YandexMusicSource { identifier.to_string() }; + let stream_url = track::fetch_download_url(&self.client, &track_id).await; + if stream_url.is_none() { + debug!( + "Yandex Music: no stream URL for track {}, falling back to mirrors", + track_id + ); + return None; + } + Some(Box::new(track::YandexMusicTrack { client: self.client.clone(), track_id, diff --git a/src/sources/yandexmusic/track.rs b/src/sources/yandexmusic/track.rs index 27e02b0c..172d415c 100644 --- a/src/sources/yandexmusic/track.rs +++ b/src/sources/yandexmusic/track.rs @@ -82,7 +82,7 @@ impl PlayableTrack for YandexMusicTrack { } } -async fn fetch_download_url(client: &Arc, id: &str) -> Option { +pub(super) async fn fetch_download_url(client: &Arc, id: &str) -> Option { let url = format!("https://api.music.yandex.net/tracks/{}/download-info", id); let resp = client.get(url).send().await.ok()?; let data: serde_json::Value = resp.json().await.ok()?;