From 6dc640cc462e7c2b51e6963091d3ecc754970504 Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 18:14:56 +0530 Subject: [PATCH 01/28] simple docs --- .gitattributes | 1 + .gitignore | 6 + docs/.gitignore | 3 + docs/.vitepress/config.js | 35 +++ docs/.vitepress/theme/index.js | 9 + docs/.vitepress/theme/style.css | 31 ++ docs/bun.lock | 360 +++++++++++++++++++++ docs/package.json | 15 + docs/public/logo.svg | 430 +++++++++++++++++++++++++ docs/src/guide/api.md | 535 ++++++++++++++++++++++++++++++++ docs/src/guide/architecture.md | 20 ++ docs/src/guide/configuration.md | 18 ++ docs/src/guide/docker.md | 26 ++ docs/src/guide/filters.md | 15 + docs/src/guide/installation.md | 55 ++++ docs/src/index.md | 18 ++ 16 files changed, 1577 insertions(+) create mode 100644 .gitattributes create mode 100644 docs/.gitignore create mode 100644 docs/.vitepress/config.js create mode 100644 docs/.vitepress/theme/index.js create mode 100644 docs/.vitepress/theme/style.css create mode 100644 docs/bun.lock create mode 100644 docs/package.json create mode 100644 docs/public/logo.svg create mode 100644 docs/src/guide/api.md create mode 100644 docs/src/guide/architecture.md create mode 100644 docs/src/guide/configuration.md create mode 100644 docs/src/guide/docker.md create mode 100644 docs/src/guide/filters.md create mode 100644 docs/src/guide/installation.md create mode 100644 docs/src/index.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a188e06 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +docs/* diff --git a/.gitignore b/.gitignore index 5f79cc2..5694164 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/docs/.gitignore b/docs/.gitignore new file mode 100644 index 0000000..c9daea5 --- /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 0000000..bca3c09 --- /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", + srcDir: './src', + base: '/', + cleanUrls: true, + themeConfig: { + logo: '/logo.svg', // Assuming a logo is or will be placed in public/ + 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' } + ] + } + ], + 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 0000000..b408de0 --- /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 0000000..52f0bdb --- /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 0000000..d3c3aba --- /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/package.json b/docs/package.json new file mode 100644 index 0000000..074a81e --- /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/logo.svg b/docs/public/logo.svg new file mode 100644 index 0000000..4a5fd18 --- /dev/null +++ b/docs/public/logo.svg @@ -0,0 +1,430 @@ + + + +image/svg+xml diff --git a/docs/src/guide/api.md b/docs/src/guide/api.md new file mode 100644 index 0000000..f00d51f --- /dev/null +++ b/docs/src/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/src/guide/architecture.md b/docs/src/guide/architecture.md new file mode 100644 index 0000000..a5d4ef9 --- /dev/null +++ b/docs/src/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/src/guide/configuration.md b/docs/src/guide/configuration.md new file mode 100644 index 0000000..f4b0a68 --- /dev/null +++ b/docs/src/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/src/guide/docker.md b/docs/src/guide/docker.md new file mode 100644 index 0000000..0455748 --- /dev/null +++ b/docs/src/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/src/guide/filters.md b/docs/src/guide/filters.md new file mode 100644 index 0000000..182d94e --- /dev/null +++ b/docs/src/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/src/guide/installation.md b/docs/src/guide/installation.md new file mode 100644 index 0000000..d584f83 --- /dev/null +++ b/docs/src/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/src/index.md b/docs/src/index.md new file mode 100644 index 0000000..6070e71 --- /dev/null +++ b/docs/src/index.md @@ -0,0 +1,18 @@ +--- +layout: doc +--- +# Introduction + +Welcome to **Rustalink**, the blazing-fast, high-performance audio server written in Rust. + +Rustalink is designed from the ground up as a fully-featured, 1:1 drop-in replacement for Lavalink, allowing you to supercharge your existing Discord music bots without needing to rewrite your client code. + +## Why Choose Rustalink? + +- 🚀 **Zero-cost abstractions:** Built on Rust to guarantee memory safety and process audio at unparalleled speeds. +- 🤝 **100% Lavalink Compatible:** Plug and play with your current Lavalink wrappers and clients. You won't notice a difference in setup, but your users will notice the quality. +- 🪶 **Featherweight Footprint:** Exceptionally low CPU and RAM usage, enabling you to scale massive bots cheaply. +- 🎧 **High-Fidelity Audio:** By using advanced 32-bit floating point (`f32`) precision for all DSP effects, Rustalink entirely eliminates the audio tearing and quantization noise found in older integer-based processors. +- 🔌 **Fully Extensible:** Leverage our robust plugin architecture to inject custom audio sources, specialized route planners, or new REST API endpoints. + +Empower your Discord bot with the most reliable, modern, and high-performance audio engine available today. From 878b7c71e9133797b5a99e3abf3917c051b78490 Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 18:17:03 +0530 Subject: [PATCH 02/28] ci: Add GitHub Actions workflow to deploy VitePress documentation to GitHub Pages. --- .github/workflows/deploy-docs.yml | 67 +++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 .github/workflows/deploy-docs.yml diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml new file mode 100644 index 0000000..901a08a --- /dev/null +++ b/.github/workflows/deploy-docs.yml @@ -0,0 +1,67 @@ +name: Deploy VitePress site to Pages + +on: + # Runs on pushes targeting the any branch + push: + paths: + - "docs/**" + - ".github/workflows/deploy-docs.yml" + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages +permissions: + contents: read + pages: write + id-token: write + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + # Build job + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 # Not needed if lastUpdated is not configured + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - 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 + + # Deployment job + 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 From 52957e2c7501f258b1e79851b81f1922c430ce99 Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 18:19:12 +0530 Subject: [PATCH 03/28] chore: remove redundant comments from deploy-docs workflow. --- .github/workflows/deploy-docs.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 901a08a..787d4a9 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -1,36 +1,30 @@ name: Deploy VitePress site to Pages on: - # Runs on pushes targeting the any branch push: paths: - "docs/**" - ".github/workflows/deploy-docs.yml" - # Allows you to run this workflow manually from the Actions tab workflow_dispatch: -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: contents: read pages: write id-token: write -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. concurrency: group: pages cancel-in-progress: false jobs: - # Build job build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: - fetch-depth: 0 # Not needed if lastUpdated is not configured + fetch-depth: 0 - name: Setup Bun uses: oven-sh/setup-bun@v1 @@ -53,7 +47,6 @@ jobs: with: path: docs/.vitepress/dist - # Deployment job deploy: environment: name: github-pages From a77dd8877e8ff60675cbc9f8be57483aa2ebe4b6 Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 18:23:10 +0530 Subject: [PATCH 04/28] docs: Update VitePress base path to `/Rustalink/` and remove logo comment. --- docs/.vitepress/config.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index bca3c09..9f571ea 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -4,10 +4,10 @@ export default defineConfig({ title: "Rustalink", description: "High-performance Rust audio server documentation", srcDir: './src', - base: '/', + base: '/Rustalink/', cleanUrls: true, themeConfig: { - logo: '/logo.svg', // Assuming a logo is or will be placed in public/ + logo: '/logo.svg', nav: [ { text: 'Docs', link: '/' } ], From 750a872641d61b5e014346bb7e0e6b1c9338d53a Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 18:47:52 +0530 Subject: [PATCH 05/28] Removed `actions/configure-pages` from the deploy workflow and added `outDir` to the VitePress configuration. --- .github/workflows/deploy-docs.yml | 3 --- docs/.vitepress/config.js | 1 + 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 787d4a9..ccd3327 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -31,9 +31,6 @@ jobs: with: bun-version: latest - - name: Setup Pages - uses: actions/configure-pages@v4 - - name: Install dependencies working-directory: docs run: bun install diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 9f571ea..566ba49 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -4,6 +4,7 @@ export default defineConfig({ title: "Rustalink", description: "High-performance Rust audio server documentation", srcDir: './src', + outDir: '../.vitepress/dist', base: '/Rustalink/', cleanUrls: true, themeConfig: { From f8850438712bdacf7407ab4ecb80d96cfba8ca61 Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 18:54:31 +0530 Subject: [PATCH 06/28] fix: Update documentation build artifact path in workflow and remove VitePress outDir configuration. --- .github/workflows/deploy-docs.yml | 2 +- docs/.vitepress/config.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index ccd3327..5a45048 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -42,7 +42,7 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: docs/.vitepress/dist + path: docs/src/.vitepress/dist deploy: environment: diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 566ba49..9f571ea 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -4,7 +4,6 @@ export default defineConfig({ title: "Rustalink", description: "High-performance Rust audio server documentation", srcDir: './src', - outDir: '../.vitepress/dist', base: '/Rustalink/', cleanUrls: true, themeConfig: { From cf85166ad2deb2dc7b68c9695bbba455e069a44b Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 19:03:09 +0530 Subject: [PATCH 07/28] refactor: remove 'src' directory from VitePress configuration and GitHub Actions deployment path. --- .github/workflows/deploy-docs.yml | 2 +- docs/.vitepress/config.js | 1 - docs/{src => }/guide/api.md | 0 docs/{src => }/guide/architecture.md | 0 docs/{src => }/guide/configuration.md | 0 docs/{src => }/guide/docker.md | 0 docs/{src => }/guide/filters.md | 0 docs/{src => }/guide/installation.md | 0 docs/{src => }/index.md | 0 9 files changed, 1 insertion(+), 2 deletions(-) rename docs/{src => }/guide/api.md (100%) rename docs/{src => }/guide/architecture.md (100%) rename docs/{src => }/guide/configuration.md (100%) rename docs/{src => }/guide/docker.md (100%) rename docs/{src => }/guide/filters.md (100%) rename docs/{src => }/guide/installation.md (100%) rename docs/{src => }/index.md (100%) diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 5a45048..ccd3327 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -42,7 +42,7 @@ jobs: - name: Upload artifact uses: actions/upload-pages-artifact@v3 with: - path: docs/src/.vitepress/dist + path: docs/.vitepress/dist deploy: environment: diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 9f571ea..3df9975 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -3,7 +3,6 @@ import { defineConfig } from 'vitepress' export default defineConfig({ title: "Rustalink", description: "High-performance Rust audio server documentation", - srcDir: './src', base: '/Rustalink/', cleanUrls: true, themeConfig: { diff --git a/docs/src/guide/api.md b/docs/guide/api.md similarity index 100% rename from docs/src/guide/api.md rename to docs/guide/api.md diff --git a/docs/src/guide/architecture.md b/docs/guide/architecture.md similarity index 100% rename from docs/src/guide/architecture.md rename to docs/guide/architecture.md diff --git a/docs/src/guide/configuration.md b/docs/guide/configuration.md similarity index 100% rename from docs/src/guide/configuration.md rename to docs/guide/configuration.md diff --git a/docs/src/guide/docker.md b/docs/guide/docker.md similarity index 100% rename from docs/src/guide/docker.md rename to docs/guide/docker.md diff --git a/docs/src/guide/filters.md b/docs/guide/filters.md similarity index 100% rename from docs/src/guide/filters.md rename to docs/guide/filters.md diff --git a/docs/src/guide/installation.md b/docs/guide/installation.md similarity index 100% rename from docs/src/guide/installation.md rename to docs/guide/installation.md diff --git a/docs/src/index.md b/docs/index.md similarity index 100% rename from docs/src/index.md rename to docs/index.md From 1e9d92440e62dcd186a3f813dd808576f7149bd6 Mon Sep 17 00:00:00 2001 From: notdeltaxd Date: Mon, 9 Mar 2026 13:37:20 +0000 Subject: [PATCH 08/28] feat(pterodactyl): add Pterodactyl egg and documentation for deployment --- docs/.vitepress/config.js | 3 +- docs/guide/pterodactyl.md | 49 ++++++++++++++++++++++++++++++ pterodactyl/egg-rustalink.json | 55 ++++++++++++++++++++++++++++++++++ 3 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 docs/guide/pterodactyl.md create mode 100644 pterodactyl/egg-rustalink.json diff --git a/docs/.vitepress/config.js b/docs/.vitepress/config.js index 3df9975..5312a88 100644 --- a/docs/.vitepress/config.js +++ b/docs/.vitepress/config.js @@ -20,7 +20,8 @@ export default defineConfig({ { text: 'Configuration', link: '/guide/configuration' }, { text: 'Architecture', link: '/guide/architecture' }, { text: 'Filters', link: '/guide/filters' }, - { text: 'REST API', link: '/guide/api' } + { text: 'REST API', link: '/guide/api' }, + { text: 'Pterodactyl', link: '/guide/pterodactyl' } ] } ], diff --git a/docs/guide/pterodactyl.md b/docs/guide/pterodactyl.md new file mode 100644 index 0000000..147648e --- /dev/null +++ b/docs/guide/pterodactyl.md @@ -0,0 +1,49 @@ +# 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 [pterodactyl/](https://github.com/bongodevs/Rustalink/tree/main/pterodactyl) directory in our repository. +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 the **Import Egg** button. +6. Upload the `egg-rustalink.json` file and 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/pterodactyl/egg-rustalink.json b/pterodactyl/egg-rustalink.json new file mode 100644 index 0000000..20c923c --- /dev/null +++ b/pterodactyl/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/main/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" + } + ] +} From 50c7ef07f57343c1ec0ef9166d1f94caca7cfd1c Mon Sep 17 00:00:00 2001 From: notdeltaxd Date: Mon, 9 Mar 2026 13:45:56 +0000 Subject: [PATCH 09/28] build(pterodactyl-egg): update installation script to fetch config from HEAD --- pterodactyl/egg-rustalink.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pterodactyl/egg-rustalink.json b/pterodactyl/egg-rustalink.json index 20c923c..b644da7 100644 --- a/pterodactyl/egg-rustalink.json +++ b/pterodactyl/egg-rustalink.json @@ -38,7 +38,7 @@ "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/main/config.example.toml -o config.toml\nfi" + "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": [ From 2b9d092375e2935b7c9870b4fb4452884ee48471 Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 23:29:07 +0530 Subject: [PATCH 10/28] feat: Refactor voice gateway session initialization to align with V8 protocol, including UDP socket sharing, pre-identify HELLO handling, and SSRC updates. --- src/gateway/session/handler.rs | 53 +++++++++++++++++++---- src/gateway/session/mod.rs | 78 ++++++++++++++++++++++------------ 2 files changed, 96 insertions(+), 35 deletions(-) diff --git a/src/gateway/session/handler.rs b/src/gateway/session/handler.rs index f8567c8..e62a4d2 100644 --- a/src/gateway/session/handler.rs +++ b/src/gateway/session/handler.rs @@ -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, @@ -82,7 +88,7 @@ impl<'a> SessionState<'a> { heartbeat: HeartbeatTracker::new(), heartbeat_handle: None, conn_token, - speaking_tx, + speaking_tx: None, session_key: None, speak_task: None, persistent_state, @@ -90,6 +96,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 } @@ -238,6 +248,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", @@ -366,6 +381,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}), @@ -400,6 +419,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}), @@ -446,6 +469,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 +483,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,6 +494,10 @@ 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; + debug!( + "[{}] DAVE Prepare Epoch: epoch={}, version={}", + self.gateway.guild_id, epoch, ver + ); self.dave.lock().await.prepare_epoch(epoch, ver); None } @@ -484,7 +521,7 @@ 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 }; self.speak_task = Some(tokio::spawn(async move { diff --git a/src/gateway/session/mod.rs b/src/gateway/session/mod.rs index 2d59009..a4b1387 100644 --- a/src/gateway/session/mod.rs +++ b/src/gateway/session/mod.rs @@ -46,6 +46,7 @@ pub struct VoiceGateway { event_tx: Option>, pub frames_sent: Arc, pub frames_nulled: Arc, + pub udp_socket: Shared>>, outer_token: CancellationToken, } @@ -85,6 +86,7 @@ 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)), outer_token: CancellationToken::new(), } } @@ -137,6 +139,7 @@ impl VoiceGateway { 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 +197,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 +215,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; From 6efe389c6e6f48047b4482003c63c8f0156da6aa Mon Sep 17 00:00:00 2001 From: appujet Date: Mon, 9 Mar 2026 23:44:43 +0530 Subject: [PATCH 11/28] feat(gateway/session): persist RTP state for voice connections --- src/gateway/session/handler.rs | 8 +++----- src/gateway/session/mod.rs | 5 +++++ src/gateway/session/types.rs | 1 + src/gateway/session/voice.rs | 17 ++++++++++++++++- src/gateway/udp_link.rs | 23 +++++++++++++---------- 5 files changed, 38 insertions(+), 16 deletions(-) diff --git a/src/gateway/session/handler.rs b/src/gateway/session/handler.rs index e62a4d2..818d414 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}; @@ -81,10 +81,7 @@ 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, @@ -522,6 +519,7 @@ impl<'a> SessionState<'a> { frames_nulled: self.gateway.frames_nulled.clone(), cancel_token: self.conn_token.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 { diff --git a/src/gateway/session/mod.rs b/src/gateway/session/mod.rs index a4b1387..f98082d 100644 --- a/src/gateway/session/mod.rs +++ b/src/gateway/session/mod.rs @@ -47,6 +47,7 @@ pub struct VoiceGateway { pub frames_sent: Arc, pub frames_nulled: Arc, pub udp_socket: Shared>>, + pub dave: Shared, outer_token: CancellationToken, } @@ -87,6 +88,10 @@ impl VoiceGateway { 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(), } } diff --git a/src/gateway/session/types.rs b/src/gateway/session/types.rs index b7bd4f2..6564723 100644 --- a/src/gateway/session/types.rs +++ b/src/gateway/session/types.rs @@ -24,6 +24,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)] diff --git a/src/gateway/session/voice.rs b/src/gateway/session/voice.rs index 0f62b4d..d64c05e 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,16 @@ 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) % 100 == 0 { + 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 661d935..ef1e36f 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), }) } From 1bca17f383976d81030b47d18363bcb0ec4c6eba Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 10:10:14 +0530 Subject: [PATCH 12/28] feat: enhance DAVE encryption session management with user tracking and sequence numbers, and refine voice gateway operation handling --- src/gateway/encryption.rs | 55 ++++++++++---- src/gateway/session/handler.rs | 120 ++++++++++++++++++++++++------- src/gateway/session/heartbeat.rs | 1 + src/gateway/session/mod.rs | 7 +- src/gateway/session/types.rs | 12 ++-- 5 files changed, 152 insertions(+), 43 deletions(-) diff --git a/src/gateway/encryption.rs b/src/gateway/encryption.rs index d93670d..b150dd1 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,18 +123,19 @@ 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>> { let mut responses = Vec::new(); @@ -122,9 +148,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); } @@ -150,8 +177,11 @@ impl DaveHandler { let transition_id = u16::from_be_bytes([data[0], data[1]]); if let Some(session) = &mut self.session { + if is_welcome { - session.process_welcome(&data[2..]).map_err(map_boxed_err)?; + session + .process_welcome(&data[2..]) + .map_err(map_boxed_err)?; } else { session.process_commit(&data[2..]).map_err(map_boxed_err)?; } @@ -168,7 +198,6 @@ impl DaveHandler { pub fn process_proposals( &mut self, data: &[u8], - connected_users: &HashSet, ) -> AnyResult>> { if data.is_empty() { return Err(short_payload_err("DAVE proposals")); @@ -188,13 +217,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 +232,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 818d414..a1ddc07 100644 --- a/src/gateway/session/handler.rs +++ b/src/gateway/session/handler.rs @@ -113,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); @@ -131,20 +129,32 @@ 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::Speaking - | VoiceOp::Video - | VoiceOp::Codecs - | VoiceOp::MediaSinkWants - | VoiceOp::VoiceBackendVersion - | VoiceOp::VoiceFlags - | VoiceOp::VoicePlatform, - ) => None, // Ignore informational events + 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::VoiceFlags | 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!( @@ -174,7 +184,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); } @@ -226,7 +236,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(()) @@ -236,7 +246,9 @@ impl<'a> SessionState<'a> { dave.reset(); self.send_json(31, serde_json::json!({ "transition_id": tid })); if let Ok(kp) = dave.setup_session(DAVE_INITIAL_VERSION) { - self.send_binary(26, &kp); + if !kp.is_empty() { + self.send_binary(26, &kp); + } } } @@ -292,6 +304,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(); @@ -389,10 +417,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(); @@ -445,20 +487,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 } @@ -495,7 +543,27 @@ impl<'a> SessionState<'a> { "[{}] DAVE Prepare Epoch: epoch={}, version={}", self.gateway.guild_id, epoch, ver ); - self.dave.lock().await.prepare_epoch(epoch, ver); + if let Some(kp) = self.dave.lock().await.prepare_epoch(epoch, ver) { + if !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 } @@ -530,7 +598,11 @@ 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 aa8587c..387d3d5 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 f98082d..7ef2e38 100644 --- a/src/gateway/session/mod.rs +++ b/src/gateway/session/mod.rs @@ -99,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() { @@ -137,7 +137,7 @@ 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; @@ -345,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, @@ -390,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(), @@ -404,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 6564723..3b1013b 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, } @@ -90,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, @@ -119,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, @@ -133,6 +135,7 @@ pub enum VoiceOp { DaveMlsAnnounceCommitTransition = 29, DaveMlsWelcome = 30, DaveMlsInvalidCommitWelcome = 31, + NoRoute = 32, } impl VoiceOp { @@ -153,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, @@ -167,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, }) } From 48e4f2498d17780d67d496e5c325fc045d07b535 Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 10:13:52 +0530 Subject: [PATCH 13/28] docs: Remove extensive module-level and function documentation from the audio demux components. --- src/audio/demux/format.rs | 2 -- src/audio/demux/mod.rs | 20 +------------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/audio/demux/format.rs b/src/audio/demux/format.rs index 6d2c62d..b6b84d1 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 98c9d2e..0adc9b4 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,8 @@ 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 +26,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, From e5829079aaa1ca3b89edbe90d1da1c682116ba80 Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 10:45:05 +0530 Subject: [PATCH 14/28] refactor: reduce audio client read timeout, add chunk claiming debug logging, and enhance segmented source fatal error reporting. --- src/audio/constants.rs | 6 +++--- src/audio/source/client.rs | 2 +- src/audio/source/segmented.rs | 7 +++++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/audio/constants.rs b/src/audio/constants.rs index 0af3346..c5d36ba 100644 --- a/src/audio/constants.rs +++ b/src/audio/constants.rs @@ -31,12 +31,12 @@ 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 MAX_CONCURRENT_FETCHES: usize = 2; +pub const PREFETCH_CHUNKS: usize = 4; +pub const MAX_CONCURRENT_FETCHES: usize = 4; 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/source/client.rs b/src/audio/source/client.rs index e2cd130..bdd23aa 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(12)) .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 931e763..34e3a8d 100644 --- a/src/audio/source/segmented.rs +++ b/src/audio/source/segmented.rs @@ -274,6 +274,7 @@ 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) }) @@ -340,10 +341,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 From f8b0e5c932ac2d28f4c758208435e2881b0f59fb Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 10:49:04 +0530 Subject: [PATCH 15/28] fix: Reduce audio client read timeout from 12 to 8 seconds. --- src/audio/source/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/audio/source/client.rs b/src/audio/source/client.rs index bdd23aa..daefc2b 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(12)) + .read_timeout(Duration::from_secs(8)) .tcp_nodelay(true) .tcp_keepalive(Duration::from_secs(25)) .pool_max_idle_per_host(64) From 4f5bba12486653e8f8fe24a787377c589010855f Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 11:03:08 +0530 Subject: [PATCH 16/28] feat: Implement retry logic for partial chunk fetches in segmented audio source. --- src/audio/constants.rs | 4 ++-- src/audio/source/segmented.rs | 18 +++++++++++++++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/audio/constants.rs b/src/audio/constants.rs index c5d36ba..80f849d 100644 --- a/src/audio/constants.rs +++ b/src/audio/constants.rs @@ -30,9 +30,9 @@ pub const LAYER_BUFFER_SIZE: usize = 1_024 * 1_024; // ── Segmented remote reader ────────────────────────────────────────────────── -pub const CHUNK_SIZE: usize = 128 * 1_024; +pub const CHUNK_SIZE: usize = 256 * 1_024; pub const PREFETCH_CHUNKS: usize = 4; -pub const MAX_CONCURRENT_FETCHES: 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; diff --git a/src/audio/source/segmented.rs b/src/audio/source/segmented.rs index 34e3a8d..64a6bc6 100644 --- a/src/audio/source/segmented.rs +++ b/src/audio/source/segmented.rs @@ -298,7 +298,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!( From 629363d19754bec18ded4e7f3485a071fe8a985d Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 11:13:39 +0530 Subject: [PATCH 17/28] docs: Update Pterodactyl egg download link in the guide. --- docs/guide/pterodactyl.md | 2 +- {pterodactyl => docs/public}/egg-rustalink.json | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename {pterodactyl => docs/public}/egg-rustalink.json (100%) diff --git a/docs/guide/pterodactyl.md b/docs/guide/pterodactyl.md index 147648e..45f4577 100644 --- a/docs/guide/pterodactyl.md +++ b/docs/guide/pterodactyl.md @@ -9,7 +9,7 @@ This guide explains how to host your own Rustalink node on a Pterodactyl panel. ## Importing the Egg -1. Download the `egg-rustalink.json` file from the [pterodactyl/](https://github.com/bongodevs/Rustalink/tree/main/pterodactyl) directory in our repository. +1. Download the `egg-rustalink.json` file from here. 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. diff --git a/pterodactyl/egg-rustalink.json b/docs/public/egg-rustalink.json similarity index 100% rename from pterodactyl/egg-rustalink.json rename to docs/public/egg-rustalink.json From d8409934dcd3695ce509ad639d24fd9d0cc92902 Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 11:28:22 +0530 Subject: [PATCH 18/28] docs: Provide Pterodactyl egg configuration directly in guide and update import instructions. --- docs/guide/pterodactyl.md | 72 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/docs/guide/pterodactyl.md b/docs/guide/pterodactyl.md index 45f4577..355f6aa 100644 --- a/docs/guide/pterodactyl.md +++ b/docs/guide/pterodactyl.md @@ -9,12 +9,78 @@ This guide explains how to host your own Rustalink node on a Pterodactyl panel. ## Importing the Egg -1. Download the `egg-rustalink.json` file from here. +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 the **Import Egg** button. -6. Upload the `egg-rustalink.json` file and click **Import**. +5. Click **Import Egg** in the top right. +6. Select the `egg-rustalink.json` file you downloaded earlier. +7. Click **Import**. ## Creating the Server From 7a9c83cf6b1055322659e74315de800ec4cc754e Mon Sep 17 00:00:00 2001 From: notdeltaxd Date: Tue, 10 Mar 2026 06:02:08 +0000 Subject: [PATCH 19/28] refactor(sources): Simplify track decoding, check stream early Move stream URL fetching and track resolvability checks to the source manager. This simplifies PlayableTrack implementations, reduces boilerplate, and allows for earlier fallback on unresolvable tracks. --- src/sources/audiomack/manager.rs | 16 ++++-- src/sources/audiomack/track.rs | 76 +++++---------------------- src/sources/deezer/mod.rs | 9 +++- src/sources/deezer/track.rs | 88 +++++++++++++++++++++++++++++++- src/sources/gaana/manager.rs | 9 ++++ src/sources/gaana/track.rs | 2 +- src/sources/yandexmusic/mod.rs | 9 ++++ src/sources/yandexmusic/track.rs | 2 +- 8 files changed, 142 insertions(+), 69 deletions(-) diff --git a/src/sources/audiomack/manager.rs b/src/sources/audiomack/manager.rs index fd8bf7d..3b275a4 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 a6dd1d9..b83f41d 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 9d497ce..fa4bdeb 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 2ef0f2b..db88a40 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 8767496..45c8f5c 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 aa31387..cac6277 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/yandexmusic/mod.rs b/src/sources/yandexmusic/mod.rs index 2e72ff0..6804a68 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 27e02b0..172d415 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()?; From ab57156935ac45c45138029813ddf1557eb8dde4 Mon Sep 17 00:00:00 2001 From: notdeltaxd Date: Tue, 10 Mar 2026 06:03:07 +0000 Subject: [PATCH 20/28] style: Clean up whitespace and simplify minor expressions --- src/audio/demux/mod.rs | 1 - src/audio/filters/timescale.rs | 4 ++-- src/audio/source/segmented.rs | 5 ++++- src/gateway/encryption.rs | 15 +++---------- src/gateway/session/handler.rs | 40 +++++++++++++++++++--------------- src/gateway/session/voice.rs | 7 +++++- 6 files changed, 38 insertions(+), 34 deletions(-) diff --git a/src/audio/demux/mod.rs b/src/audio/demux/mod.rs index 0adc9b4..6a550e8 100644 --- a/src/audio/demux/mod.rs +++ b/src/audio/demux/mod.rs @@ -15,7 +15,6 @@ pub use webm_opus::WebmOpusDemuxer; use crate::audio::constants::{MIXER_CHANNELS, TARGET_SAMPLE_RATE}; pub use crate::common::types::AudioFormat; - pub enum DemuxResult { Transcode { format: Box, diff --git a/src/audio/filters/timescale.rs b/src/audio/filters/timescale.rs index a006807..973170c 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/segmented.rs b/src/audio/source/segmented.rs index 64a6bc6..878828f 100644 --- a/src/audio/source/segmented.rs +++ b/src/audio/source/segmented.rs @@ -274,7 +274,10 @@ async fn fetch_worker( claimed } .map(|(idx, retries)| { - debug!("Worker {}: claiming chunk {} (retry={})", worker_id, idx, retries); + debug!( + "Worker {}: claiming chunk {} (retry={})", + worker_id, idx, retries + ); state.chunks.insert(idx, ChunkState::Downloading); (idx, retries, total_len) }) diff --git a/src/gateway/encryption.rs b/src/gateway/encryption.rs index b150dd1..cd12bdf 100644 --- a/src/gateway/encryption.rs +++ b/src/gateway/encryption.rs @@ -133,10 +133,7 @@ impl DaveHandler { None } - pub fn process_external_sender( - &mut self, - data: &[u8], - ) -> AnyResult>> { + pub fn process_external_sender(&mut self, data: &[u8]) -> AnyResult>> { let mut responses = Vec::new(); if let Some(session) = &mut self.session { @@ -177,11 +174,8 @@ impl DaveHandler { let transition_id = u16::from_be_bytes([data[0], data[1]]); if let Some(session) = &mut self.session { - if is_welcome { - session - .process_welcome(&data[2..]) - .map_err(map_boxed_err)?; + session.process_welcome(&data[2..]).map_err(map_boxed_err)?; } else { session.process_commit(&data[2..]).map_err(map_boxed_err)?; } @@ -195,10 +189,7 @@ impl DaveHandler { Ok(transition_id) } - pub fn process_proposals( - &mut self, - data: &[u8], - ) -> AnyResult>> { + pub fn process_proposals(&mut self, data: &[u8]) -> AnyResult>> { if data.is_empty() { return Err(short_payload_err("DAVE proposals")); } diff --git a/src/gateway/session/handler.rs b/src/gateway/session/handler.rs index a1ddc07..1435ee3 100644 --- a/src/gateway/session/handler.rs +++ b/src/gateway/session/handler.rs @@ -131,9 +131,19 @@ impl<'a> SessionState<'a> { Some(VoiceOp::Resumed) => self.handle_resumed().await, 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::VoiceFlags | VoiceOp::VoicePlatform | VoiceOp::DaveTransitionReady) => None, // Ignore informational events + Some( + VoiceOp::Speaking + | VoiceOp::Video + | VoiceOp::Codecs + | VoiceOp::VoiceFlags + | VoiceOp::VoicePlatform + | VoiceOp::DaveTransitionReady, + ) => None, // Ignore informational events Some(VoiceOp::VoiceBackendVersion) => { - info!("[{}] Voice Backend Version: {:?}", self.gateway.guild_id, msg.d); + info!( + "[{}] Voice Backend Version: {:?}", + self.gateway.guild_id, msg.d + ); None } Some(VoiceOp::MediaSinkWants) => { @@ -245,10 +255,10 @@ 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 !kp.is_empty() { - self.send_binary(26, &kp); - } + if let Ok(kp) = dave.setup_session(DAVE_INITIAL_VERSION) + && !kp.is_empty() + { + self.send_binary(26, &kp); } } @@ -308,7 +318,7 @@ impl<'a> SessionState<'a> { 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); @@ -543,16 +553,16 @@ impl<'a> SessionState<'a> { "[{}] DAVE Prepare Epoch: epoch={}, version={}", self.gateway.guild_id, epoch, ver ); - if let Some(kp) = self.dave.lock().await.prepare_epoch(epoch, ver) { - if !kp.is_empty() { - self.send_binary(26, &kp); - } + 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; + let tid = d["transition_id"].as_u64().unwrap_or(0) as u16; debug!( "[{}] DAVE MLS Announce Commit Transition: tid={}", self.gateway.guild_id, tid @@ -598,11 +608,7 @@ impl<'a> SessionState<'a> { } fn send_json(&self, op: u8, d: Value) { - let msg = VoiceGatewayMessage { - op, - seq: None, - 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/voice.rs b/src/gateway/session/voice.rs index d64c05e..3139679 100644 --- a/src/gateway/session/voice.rs +++ b/src/gateway/session/voice.rs @@ -143,7 +143,12 @@ impl VoiceSession { interval.tick().await; self.tick(encoder, &mut pcm, &mut opus, &mut ts_pcm).await?; - if self.config.frames_sent.load(Ordering::Relaxed) % 100 == 0 { + 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); } From 50d0030134a103e8517fe6b294b5f6739477d8b0 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:24:43 +0000 Subject: [PATCH 21/28] feat(mirrors): implement scored mirror resolution pipeline --- config.example.toml | 35 ++- src/config/server.rs | 31 ++- src/sources/manager/resolver.rs | 436 +++++++++++++++++++++++++++++--- 3 files changed, 467 insertions(+), 35 deletions(-) diff --git a/config.example.toml b/config.example.toml index ad4a17e..899f02a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -16,9 +16,40 @@ opus_encoding_quality = 10 # tape = { tape_stop = true, tape_stop_duration_ms = 500, curve = "sinusoidal" } # curve: linear | exponential | sinusoidal [player.mirrors] -# List of mirror provider patterns. %ISRC% or %QUERY% +# Provider patterns tried when the primary source fails to resolve a track. +# Placeholders: %ISRC% (track ISRC code) and %QUERY% (title + artist). +# Providers without %ISRC% in their pattern are always tried; those with %ISRC% +# are skipped automatically when the track has no ISRC. providers = ["ytsearch:%ISRC%", "ytsearch:%QUERY%", "scsearch:%QUERY%"] +# 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) — these providers are run +# sequentially and only after all parallel free providers have been exhausted. +# Default: ["ytmsearch:", "ytsearch:"] +throttled_prefixes = ["ytmsearch:", "ytsearch:"] + +# ── Confidence thresholds (only used when scoring = true) +# +# 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. +# All values are in the range [0.0, 1.0]. +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" @@ -258,4 +289,4 @@ spatial = true [metrics.prometheus] enabled = false -endpoint = "/metrics" +endpoint = "/metrics" \ No newline at end of file diff --git a/src/config/server.rs b/src/config/server.rs index d9cee81..d2d08d7 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -77,10 +77,37 @@ pub struct RoutePlannerConfig { pub excluded_ips: Vec, } -#[derive(Debug, Deserialize, Serialize, Clone, Default)] + +#[derive(Debug, Deserialize, Serialize, Clone)] #[serde(default)] pub struct MirrorsConfig { pub providers: Vec, + 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 MirrorsConfig { + fn default() -> Self { + Self { + providers: Vec::new(), + 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)] @@ -88,4 +115,4 @@ pub struct ConfigServerConfig { pub url: String, pub username: Option, pub password: Option, -} +} \ No newline at end of file diff --git a/src/sources/manager/resolver.rs b/src/sources/manager/resolver.rs index 5a770b1..3b9ed50 100644 --- a/src/sources/manager/resolver.rs +++ b/src/sources/manager/resolver.rs @@ -1,8 +1,141 @@ use std::sync::Arc; +use futures::stream::{FuturesUnordered, StreamExt}; + use crate::sources::{manager::SourceManager, plugin::BoxedTrack}; -/// Fallback mechanism to resolve a track using mirrors (ISRC or search queries). + +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::MirrorsConfig, +) -> 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) +} + + +struct MirrorResult { + track: BoxedTrack, + score: f64, + provider: String, +} + + pub async fn resolve_with_mirrors( manager: &SourceManager, track_info: &crate::protocol::tracks::TrackInfo, @@ -10,40 +143,37 @@ pub async fn resolve_with_mirrors( mirrors: &crate::config::server::MirrorsConfig, routeplanner: Option>, ) -> Option { - let isrc = track_info.isrc.as_deref().unwrap_or(""); - let query = format!("{} - {}", track_info.title, track_info.author); + if mirrors.scoring { + resolve_scored(manager, track_info, identifier, mirrors, routeplanner).await + } else { + resolve_first(manager, track_info, identifier, mirrors, routeplanner).await + } +} - let original_source_name = manager - .sources - .iter() - .find(|s| s.can_handle(identifier)) - .map(|s| s.name()); +// ── Scoring disabled: +/// Try each provider in order and return the very first resolvable track. +async fn resolve_first( + manager: &SourceManager, + track_info: &crate::protocol::tracks::TrackInfo, + identifier: &str, + mirrors: &crate::config::server::MirrorsConfig, + routeplanner: Option>, +) -> Option { + let isrc = track_info.isrc.as_deref().unwrap_or(""); + let query = build_query(track_info); + let original_source_name = source_name_for(manager, identifier); for provider in &mirrors.providers { - if isrc.is_empty() && provider.contains("%ISRC%") { - tracing::debug!("Skipping mirror provider '{}': track has no ISRC", provider); - continue; - } - - let resolved = provider.replace("%ISRC%", isrc).replace("%QUERY%", &query); - - if let Some(handling_source) = manager.sources.iter().find(|s| s.can_handle(&resolved)) { - if handling_source.is_mirror() { - tracing::warn!( - "Skipping mirror provider '{}': '{}' is a Mirror-type source", - resolved, - handling_source.name() - ); - continue; - } - if Some(handling_source.name()) == original_source_name { - tracing::debug!( - "Skipping mirror provider '{}': would loop back to '{}'", - resolved, - handling_source.name() - ); + let resolved = match expand_provider(provider, isrc, &query) { + Some(r) => r, + None => { + tracing::debug!("Skipping mirror provider '{}': track has no ISRC", provider); continue; } + }; + + if should_skip(&resolved, manager, original_source_name.as_deref()) { + continue; } let res = match manager.load(&resolved, routeplanner.clone()).await { @@ -63,6 +193,7 @@ pub async fn resolve_with_mirrors( }; if let Some(track) = res { + tracing::debug!("[Mirror] first-match accepted via '{}'", resolved); return Some(track); } } @@ -70,7 +201,201 @@ pub async fn resolve_with_mirrors( None } -/// Helper to resolve a playable track from a source after a mirror redirect. +// ── Scoring enabled: parallel-race + sequential fallback +/// 1. Split providers into **free** (parallel race) and **throttled** (sequential, +/// last resort) based on `mirrors.throttled_prefixes`. +/// 2. Race free providers via `FuturesUnordered`; return immediately on +/// `immediate_use` score, otherwise collect the global best. +/// 3. If no free result clears `immediate_use`, try throttled providers +/// sequentially, accepting any score above `min_similarity`. +/// 4. Last resort: return the best free-phase result even if below `immediate_use`. +async fn resolve_scored( + manager: &SourceManager, + track_info: &crate::protocol::tracks::TrackInfo, + identifier: &str, + mirrors: &crate::config::server::MirrorsConfig, + routeplanner: Option>, +) -> Option { + let isrc = track_info.isrc.as_deref().unwrap_or(""); + let query = build_query(track_info); + let original_source_name = source_name_for(manager, identifier); + + let mut free_providers: Vec = Vec::new(); + let mut throttled_providers: Vec = Vec::new(); + + for provider in &mirrors.providers { + let resolved = match expand_provider(provider, isrc, &query) { + Some(r) => r, + None => { + tracing::debug!("Skipping mirror provider '{}': track has no ISRC", provider); + continue; + } + }; + + if should_skip(&resolved, manager, original_source_name.as_deref()) { + continue; + } + + if mirrors + .throttled_prefixes + .iter() + .any(|p| resolved.starts_with(p.as_str())) + { + throttled_providers.push(resolved); + } else { + free_providers.push(resolved); + } + } + + 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(), mirrors, 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 >= mirrors.immediate_use { + return Some(mr.track); + } + + if global_best.as_ref().map_or(true, |b| mr.score > b.score) { + global_best = Some(mr); + } + } + } + + + if global_best + .as_ref() + .is_some_and(|b| b.score >= mirrors.immediate_use) + { + let best = global_best.unwrap(); + tracing::info!( + "[Mirror] free-phase winner \"{}\" via {} (score {:.3})", + track_info.title, + best.provider, + best.score + ); + return Some(best.track); + } + } + + for provider in &throttled_providers { + if let Some(mr) = + search_provider(manager, track_info, provider, routeplanner.clone(), mirrors, true) + .await + { + tracing::info!( + "[Mirror] throttled match \"{}\" via {} (score {:.3})", + track_info.title, + mr.provider, + mr.score + ); + return Some(mr.track); + } + } + + if let Some(best) = global_best { + tracing::info!( + "[Mirror] fallback match \"{}\" via {} (score {:.3})", + track_info.title, + best.provider, + best.score + ); + return Some(best.track); + } + + tracing::warn!( + "[Mirror] no valid mirror found for \"{}\" | {}", + track_info.title, + track_info.author + ); + None +} + + +async fn search_provider( + manager: &SourceManager, + original: &crate::protocol::tracks::TrackInfo, + resolved_provider: &str, + routeplanner: Option>, + cfg: &crate::config::server::MirrorsConfig, + 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) = resolve_nested_track(manager, id, routeplanner.clone()).await { + return Some(MirrorResult { + track, + score, + provider: resolved_provider.to_string(), + }); + } + } + + None +} + + async fn resolve_nested_track( manager: &SourceManager, identifier: &str, @@ -85,3 +410,52 @@ async fn resolve_nested_track( } None } + + +fn build_query(track_info: &crate::protocol::tracks::TrackInfo) -> String { + if !track_info.author.is_empty() && track_info.author != "unknown" { + format!("{} {}", track_info.title, track_info.author) + } else { + track_info.title.clone() + } +} + + +fn expand_provider(provider: &str, isrc: &str, query: &str) -> Option { + if isrc.is_empty() && provider.contains("%ISRC%") { + return None; + } + Some(provider.replace("%ISRC%", isrc).replace("%QUERY%", query)) +} + + +fn source_name_for(manager: &SourceManager, identifier: &str) -> Option { + manager + .sources + .iter() + .find(|s| s.can_handle(identifier)) + .map(|s| s.name().to_string()) +} + + +fn should_skip(resolved: &str, manager: &SourceManager, original_source_name: Option<&str>) -> bool { + 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() + ); + return true; + } + if Some(src.name()) == original_source_name { + tracing::debug!( + "Skipping mirror provider '{}': would loop back to '{}'", + resolved, + src.name() + ); + return true; + } + } + false +} \ No newline at end of file From 38783ffeb67661517bac57dad3ac7a79b07c8440 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Tue, 10 Mar 2026 13:46:17 +0530 Subject: [PATCH 22/28] config(config.example): fix typography MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lovely copilot auto complete ✨ --- config.example.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.example.toml b/config.example.toml index 899f02a..95e49fd 100644 --- a/config.example.toml +++ b/config.example.toml @@ -16,7 +16,7 @@ opus_encoding_quality = 10 # tape = { tape_stop = true, tape_stop_duration_ms = 500, curve = "sinusoidal" } # curve: linear | exponential | sinusoidal [player.mirrors] -# Provider patterns tried when the primary source fails to resolve a track. +# Provider patterns tried when the source is a mirrored source (metadata only no playable streams) # Placeholders: %ISRC% (track ISRC code) and %QUERY% (title + artist). # Providers without %ISRC% in their pattern are always tried; those with %ISRC% # are skipped automatically when the track has no ISRC. @@ -289,4 +289,4 @@ spatial = true [metrics.prometheus] enabled = false -endpoint = "/metrics" \ No newline at end of file +endpoint = "/metrics" From 666e127a820e5e627631b1fc7a7097dc2e0621a1 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Tue, 10 Mar 2026 12:50:39 +0000 Subject: [PATCH 23/28] feat(mirrors): extract resolve_first and resolve_scored into separate paths --- config.example.toml | 21 +- src/config/server.rs | 19 +- src/sources/manager/best_match.rs | 330 ++++++++++++++++++++++ src/sources/manager/mod.rs | 1 + src/sources/manager/resolver.rs | 438 +++--------------------------- 5 files changed, 389 insertions(+), 420 deletions(-) create mode 100644 src/sources/manager/best_match.rs diff --git a/config.example.toml b/config.example.toml index 95e49fd..a7fc24b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -16,33 +16,28 @@ opus_encoding_quality = 10 # tape = { tape_stop = true, tape_stop_duration_ms = 500, curve = "sinusoidal" } # curve: linear | exponential | sinusoidal [player.mirrors] -# Provider patterns tried when the source is a mirrored source (metadata only no playable streams) -# Placeholders: %ISRC% (track ISRC code) and %QUERY% (title + artist). -# Providers without %ISRC% in their pattern are always tried; those with %ISRC% -# are skipped automatically when the track has no ISRC. -providers = ["ytsearch:%ISRC%", "ytsearch:%QUERY%", "scsearch:%QUERY%"] +# List of mirror provider patterns. %ISRC% or %QUERY% +providers = ["jssearch:%QUERY%", "amksearch:%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) — these providers are run -# sequentially and only after all parallel free providers have been exhausted. +# 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) -# -# immediate_use — accept instantly, cancel remaining parallel searches. +# 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. -# All values are in the range [0.0, 1.0]. immediate_use = 0.88 high_confidence = 0.75 min_similarity = 0.50 -# ── Scoring weights (should sum to 1.0) +# Scoring weights (should sum to 1.0). weight_title = 0.50 weight_artist = 0.30 weight_duration = 0.20 @@ -289,4 +284,4 @@ spatial = true [metrics.prometheus] enabled = false -endpoint = "/metrics" +endpoint = "/metrics" \ No newline at end of file diff --git a/src/config/server.rs b/src/config/server.rs index d2d08d7..2e557f9 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -77,11 +77,25 @@ pub struct RoutePlannerConfig { pub excluded_ips: Vec, } - #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(default)] pub struct MirrorsConfig { pub providers: Vec, + pub best_match: BestMatchConfig, +} + +impl Default for MirrorsConfig { + fn default() -> Self { + Self { + providers: Vec::new(), + best_match: BestMatchConfig::default(), + } + } +} + +#[derive(Debug, Deserialize, Serialize, Clone)] +#[serde(default)] +pub struct BestMatchConfig { pub scoring: bool, pub throttled_prefixes: Vec, pub min_similarity: f64, @@ -93,10 +107,9 @@ pub struct MirrorsConfig { pub duration_tolerance_ms: u64, } -impl Default for MirrorsConfig { +impl Default for BestMatchConfig { fn default() -> Self { Self { - providers: Vec::new(), scoring: true, throttled_prefixes: vec!["ytmsearch:".into(), "ytsearch:".into()], min_similarity: 0.50, diff --git a/src/sources/manager/best_match.rs b/src/sources/manager/best_match.rs new file mode 100644 index 0000000..33925f7 --- /dev/null +++ b/src/sources/manager/best_match.rs @@ -0,0 +1,330 @@ +use std::sync::Arc; + +use futures::stream::{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>, +) -> Option { + 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 free_providers: Vec = Vec::new(); + let mut throttled_providers: Vec = Vec::new(); + + for provider in &mirrors.providers { + if isrc.is_empty() && provider.contains("%ISRC%") { + 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 cfg + .throttled_prefixes + .iter() + .any(|p| resolved.starts_with(p.as_str())) + { + throttled_providers.push(resolved); + } else { + free_providers.push(resolved); + } + } + + 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 Some(mr.track); + } + + if global_best.as_ref().map_or(true, |b| mr.score > b.score) { + global_best = Some(mr); + } + } + } + + if global_best + .as_ref() + .is_some_and(|b| b.score >= cfg.immediate_use) + { + let best = global_best.unwrap(); + tracing::info!( + "[Mirror] free-phase winner \"{}\" via {} (score {:.3})", + track_info.title, + best.provider, + best.score + ); + return Some(best.track); + } + } + + 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 Some(mr.track); + } + } + + if let Some(best) = global_best { + tracing::info!( + "[Mirror] fallback match \"{}\" via {} (score {:.3})", + track_info.title, + best.provider, + best.score + ); + return Some(best.track); + } + + tracing::warn!( + "[Mirror] no valid mirror found for \"{}\" | {}", + track_info.title, + track_info.author + ); + None +} + +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 +} \ No newline at end of file diff --git a/src/sources/manager/mod.rs b/src/sources/manager/mod.rs index 4d02a07..408459b 100644 --- a/src/sources/manager/mod.rs +++ b/src/sources/manager/mod.rs @@ -7,6 +7,7 @@ use crate::{ mod registration; mod resolver; +mod best_match; /// Source Manager handles the lifecycle and coordination of all audio sources. pub struct SourceManager { diff --git a/src/sources/manager/resolver.rs b/src/sources/manager/resolver.rs index 3b9ed50..ef2bedd 100644 --- a/src/sources/manager/resolver.rs +++ b/src/sources/manager/resolver.rs @@ -1,141 +1,8 @@ use std::sync::Arc; -use futures::stream::{FuturesUnordered, StreamExt}; - use crate::sources::{manager::SourceManager, plugin::BoxedTrack}; - -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::MirrorsConfig, -) -> 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) -} - - -struct MirrorResult { - track: BoxedTrack, - score: f64, - provider: String, -} - - +/// Fallback mechanism to resolve a track using mirrors (ISRC or search queries). pub async fn resolve_with_mirrors( manager: &SourceManager, track_info: &crate::protocol::tracks::TrackInfo, @@ -143,37 +10,44 @@ pub async fn resolve_with_mirrors( mirrors: &crate::config::server::MirrorsConfig, routeplanner: Option>, ) -> Option { - if mirrors.scoring { - resolve_scored(manager, track_info, identifier, mirrors, routeplanner).await - } else { - resolve_first(manager, track_info, identifier, mirrors, routeplanner).await + if mirrors.best_match.scoring { + return super::best_match::resolve_scored(manager, track_info, identifier, mirrors, routeplanner).await; } -} -// ── Scoring disabled: -/// Try each provider in order and return the very first resolvable track. -async fn resolve_first( - manager: &SourceManager, - track_info: &crate::protocol::tracks::TrackInfo, - identifier: &str, - mirrors: &crate::config::server::MirrorsConfig, - routeplanner: Option>, -) -> Option { let isrc = track_info.isrc.as_deref().unwrap_or(""); - let query = build_query(track_info); - let original_source_name = source_name_for(manager, identifier); + let query = format!("{} - {}", track_info.title, track_info.author); + + let original_source_name = manager + .sources + .iter() + .find(|s| s.can_handle(identifier)) + .map(|s| s.name()); for provider in &mirrors.providers { - let resolved = match expand_provider(provider, isrc, &query) { - Some(r) => r, - None => { - tracing::debug!("Skipping mirror provider '{}': track has no ISRC", provider); + if isrc.is_empty() && provider.contains("%ISRC%") { + tracing::debug!("Skipping mirror provider '{}': track has no ISRC", provider); + continue; + } + + let resolved = provider.replace("%ISRC%", isrc).replace("%QUERY%", &query); + + if let Some(handling_source) = manager.sources.iter().find(|s| s.can_handle(&resolved)) { + if handling_source.is_mirror() { + tracing::warn!( + "Skipping mirror provider '{}': '{}' is a Mirror-type source", + resolved, + handling_source.name() + ); + continue; + } + if Some(handling_source.name()) == original_source_name { + tracing::debug!( + "Skipping mirror provider '{}': would loop back to '{}'", + resolved, + handling_source.name() + ); continue; } - }; - - if should_skip(&resolved, manager, original_source_name.as_deref()) { - continue; } let res = match manager.load(&resolved, routeplanner.clone()).await { @@ -193,7 +67,6 @@ async fn resolve_first( }; if let Some(track) = res { - tracing::debug!("[Mirror] first-match accepted via '{}'", resolved); return Some(track); } } @@ -201,202 +74,8 @@ async fn resolve_first( None } -// ── Scoring enabled: parallel-race + sequential fallback -/// 1. Split providers into **free** (parallel race) and **throttled** (sequential, -/// last resort) based on `mirrors.throttled_prefixes`. -/// 2. Race free providers via `FuturesUnordered`; return immediately on -/// `immediate_use` score, otherwise collect the global best. -/// 3. If no free result clears `immediate_use`, try throttled providers -/// sequentially, accepting any score above `min_similarity`. -/// 4. Last resort: return the best free-phase result even if below `immediate_use`. -async fn resolve_scored( - manager: &SourceManager, - track_info: &crate::protocol::tracks::TrackInfo, - identifier: &str, - mirrors: &crate::config::server::MirrorsConfig, - routeplanner: Option>, -) -> Option { - let isrc = track_info.isrc.as_deref().unwrap_or(""); - let query = build_query(track_info); - let original_source_name = source_name_for(manager, identifier); - - let mut free_providers: Vec = Vec::new(); - let mut throttled_providers: Vec = Vec::new(); - - for provider in &mirrors.providers { - let resolved = match expand_provider(provider, isrc, &query) { - Some(r) => r, - None => { - tracing::debug!("Skipping mirror provider '{}': track has no ISRC", provider); - continue; - } - }; - - if should_skip(&resolved, manager, original_source_name.as_deref()) { - continue; - } - - if mirrors - .throttled_prefixes - .iter() - .any(|p| resolved.starts_with(p.as_str())) - { - throttled_providers.push(resolved); - } else { - free_providers.push(resolved); - } - } - - 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(), mirrors, 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 >= mirrors.immediate_use { - return Some(mr.track); - } - - if global_best.as_ref().map_or(true, |b| mr.score > b.score) { - global_best = Some(mr); - } - } - } - - - if global_best - .as_ref() - .is_some_and(|b| b.score >= mirrors.immediate_use) - { - let best = global_best.unwrap(); - tracing::info!( - "[Mirror] free-phase winner \"{}\" via {} (score {:.3})", - track_info.title, - best.provider, - best.score - ); - return Some(best.track); - } - } - - for provider in &throttled_providers { - if let Some(mr) = - search_provider(manager, track_info, provider, routeplanner.clone(), mirrors, true) - .await - { - tracing::info!( - "[Mirror] throttled match \"{}\" via {} (score {:.3})", - track_info.title, - mr.provider, - mr.score - ); - return Some(mr.track); - } - } - - if let Some(best) = global_best { - tracing::info!( - "[Mirror] fallback match \"{}\" via {} (score {:.3})", - track_info.title, - best.provider, - best.score - ); - return Some(best.track); - } - - tracing::warn!( - "[Mirror] no valid mirror found for \"{}\" | {}", - track_info.title, - track_info.author - ); - None -} - - -async fn search_provider( - manager: &SourceManager, - original: &crate::protocol::tracks::TrackInfo, - resolved_provider: &str, - routeplanner: Option>, - cfg: &crate::config::server::MirrorsConfig, - 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) = resolve_nested_track(manager, id, routeplanner.clone()).await { - return Some(MirrorResult { - track, - score, - provider: resolved_provider.to_string(), - }); - } - } - - None -} - - -async fn resolve_nested_track( +/// Helper to resolve a playable track from a source after a mirror redirect. +pub async fn resolve_nested_track( manager: &SourceManager, identifier: &str, routeplanner: Option>, @@ -409,53 +88,4 @@ async fn resolve_nested_track( } } None -} - - -fn build_query(track_info: &crate::protocol::tracks::TrackInfo) -> String { - if !track_info.author.is_empty() && track_info.author != "unknown" { - format!("{} {}", track_info.title, track_info.author) - } else { - track_info.title.clone() - } -} - - -fn expand_provider(provider: &str, isrc: &str, query: &str) -> Option { - if isrc.is_empty() && provider.contains("%ISRC%") { - return None; - } - Some(provider.replace("%ISRC%", isrc).replace("%QUERY%", query)) -} - - -fn source_name_for(manager: &SourceManager, identifier: &str) -> Option { - manager - .sources - .iter() - .find(|s| s.can_handle(identifier)) - .map(|s| s.name().to_string()) -} - - -fn should_skip(resolved: &str, manager: &SourceManager, original_source_name: Option<&str>) -> bool { - 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() - ); - return true; - } - if Some(src.name()) == original_source_name { - tracing::debug!( - "Skipping mirror provider '{}': would loop back to '{}'", - resolved, - src.name() - ); - return true; - } - } - false } \ No newline at end of file From b1c11391e6b6013c455b738a4d2d39ec4299f481 Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Tue, 10 Mar 2026 18:23:49 +0530 Subject: [PATCH 24/28] fix(config): revert to defaults --- config.example.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/config.example.toml b/config.example.toml index a7fc24b..6e0beff 100644 --- a/config.example.toml +++ b/config.example.toml @@ -17,7 +17,7 @@ tape = { tape_stop = true, tape_stop_duration_ms = 500, curve = "sinusoidal" } # [player.mirrors] # List of mirror provider patterns. %ISRC% or %QUERY% -providers = ["jssearch:%QUERY%", "amksearch:%QUERY%", "scsearch:%QUERY%"] +providers = ["ytsearch:%ISRC%", "ytsearch:%QUERY%", "scsearch:%QUERY%"] [player.mirrors.best_match] # Enable weighted scoring to find the best-matching candidate. @@ -284,4 +284,4 @@ spatial = true [metrics.prometheus] enabled = false -endpoint = "/metrics" \ No newline at end of file +endpoint = "/metrics" From 015e9e2a6a8601f078576e80d7cb0d5835d5648b Mon Sep 17 00:00:00 2001 From: Priyanshuuuu <167881010+bre4d777@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:07:43 +0530 Subject: [PATCH 25/28] improve(mirrors): prioritize ISRC providers, remove unreachable block the post-loop `is_some_and(>= immediate_use)` check on `global_best` could never be true: `global_best` is only assigned inside the `score < immediate_use` branch, so its score is guaranteed below the threshold --- src/sources/manager/best_match.rs | 46 +++++++++++++++++++------------ 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/src/sources/manager/best_match.rs b/src/sources/manager/best_match.rs index 33925f7..7d5d0d8 100644 --- a/src/sources/manager/best_match.rs +++ b/src/sources/manager/best_match.rs @@ -145,11 +145,14 @@ pub async fn resolve_scored( .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 { - if isrc.is_empty() && provider.contains("%ISRC%") { + 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; } @@ -175,7 +178,9 @@ pub async fn resolve_scored( } } - if cfg + if is_isrc_provider { + isrc_providers.push(resolved); + } else if cfg .throttled_prefixes .iter() .any(|p| resolved.starts_with(p.as_str())) @@ -186,6 +191,27 @@ pub async fn resolve_scored( } } + if !isrc_providers.is_empty() { + let mut futs: FuturesUnordered<_> = 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 Some(mr.track); + } + } + } + let mut global_best: Option = None; if !free_providers.is_empty() { @@ -214,20 +240,6 @@ pub async fn resolve_scored( } } } - - if global_best - .as_ref() - .is_some_and(|b| b.score >= cfg.immediate_use) - { - let best = global_best.unwrap(); - tracing::info!( - "[Mirror] free-phase winner \"{}\" via {} (score {:.3})", - track_info.title, - best.provider, - best.score - ); - return Some(best.track); - } } for provider in &throttled_providers { @@ -327,4 +339,4 @@ async fn search_provider( } None -} \ No newline at end of file +} From e2fc6e92791dad7a886be32919754cd08ad7b662 Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 19:47:22 +0530 Subject: [PATCH 26/28] feat: Set initial volume for newly created track handles. --- src/player/manager/start.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/player/manager/start.rs b/src/player/manager/start.rs index ced49b4..fd04907 100644 --- a/src/player/manager/start.rs +++ b/src/player/manager/start.rs @@ -103,6 +103,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; From ec6f8b51a44d9c0ca67450ec65b4af70feb43080 Mon Sep 17 00:00:00 2001 From: notdeltaxd Date: Tue, 10 Mar 2026 14:45:37 +0000 Subject: [PATCH 27/28] feat(mirrors): improve fallback logic and ISRC selection - Prevent specialized sources from falling back to HttpSource during nested resolution. - Use FuturesOrdered for ISRC resolution to honor provider order. - Implement friendly "No mirror found" error reporting. - Fix clippy warnings (is_none_or, derivable Default). --- src/config/server.rs | 13 ++------- src/player/manager/start.rs | 13 +++------ src/sources/manager/best_match.rs | 48 ++++++++++++++++++++----------- src/sources/manager/mod.rs | 13 +++++---- src/sources/manager/resolver.rs | 37 ++++++++++++++++++------ 5 files changed, 73 insertions(+), 51 deletions(-) diff --git a/src/config/server.rs b/src/config/server.rs index 2e557f9..b34673b 100644 --- a/src/config/server.rs +++ b/src/config/server.rs @@ -77,22 +77,13 @@ pub struct RoutePlannerConfig { pub excluded_ips: Vec, } -#[derive(Debug, Deserialize, Serialize, Clone)] +#[derive(Debug, Deserialize, Serialize, Clone, Default)] #[serde(default)] pub struct MirrorsConfig { pub providers: Vec, pub best_match: BestMatchConfig, } -impl Default for MirrorsConfig { - fn default() -> Self { - Self { - providers: Vec::new(), - best_match: BestMatchConfig::default(), - } - } -} - #[derive(Debug, Deserialize, Serialize, Clone)] #[serde(default)] pub struct BestMatchConfig { @@ -128,4 +119,4 @@ pub struct ConfigServerConfig { pub url: String, pub username: Option, pub password: Option, -} \ No newline at end of file +} diff --git a/src/player/manager/start.rs b/src/player/manager/start.rs index ced49b4..4c1b16c 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(_) => { diff --git a/src/sources/manager/best_match.rs b/src/sources/manager/best_match.rs index 7d5d0d8..15e59d8 100644 --- a/src/sources/manager/best_match.rs +++ b/src/sources/manager/best_match.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use futures::stream::{FuturesUnordered, StreamExt}; +use futures::stream::{FuturesOrdered, FuturesUnordered, StreamExt}; use crate::sources::{manager::SourceManager, plugin::BoxedTrack}; @@ -32,7 +32,13 @@ fn normalize(s: &str) -> String { let clean: String = stripped .chars() - .map(|c| if c.is_alphanumeric() || c == ' ' { c } else { ' ' }) + .map(|c| { + if c.is_alphanumeric() || c == ' ' { + c + } else { + ' ' + } + }) .collect(); clean.split_whitespace().collect::>().join(" ") @@ -48,9 +54,7 @@ fn levenshtein(a: &str, b: &str) -> usize { 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); + curr[j] = (prev[j] + 1).min(curr[j - 1] + 1).min(prev[j - 1] + cost); } std::mem::swap(&mut prev, &mut curr); } @@ -134,7 +138,7 @@ pub async fn resolve_scored( identifier: &str, mirrors: &crate::config::server::MirrorsConfig, routeplanner: Option>, -) -> 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; @@ -192,7 +196,7 @@ pub async fn resolve_scored( } if !isrc_providers.is_empty() { - let mut futs: FuturesUnordered<_> = isrc_providers + let mut futs: FuturesOrdered<_> = isrc_providers .iter() .map(|p| search_provider(manager, track_info, p, routeplanner.clone(), cfg, true)) .collect(); @@ -207,7 +211,7 @@ pub async fn resolve_scored( mr.provider, mr.score, ); - return Some(mr.track); + return Ok(mr.track); } } } @@ -232,10 +236,10 @@ pub async fn resolve_scored( ); if mr.score >= cfg.immediate_use { - return Some(mr.track); + return Ok(mr.track); } - if global_best.as_ref().map_or(true, |b| mr.score > b.score) { + if global_best.as_ref().is_none_or(|b| mr.score > b.score) { global_best = Some(mr); } } @@ -243,8 +247,15 @@ pub async fn resolve_scored( } for provider in &throttled_providers { - if let Some(mr) = - search_provider(manager, track_info, provider, routeplanner.clone(), cfg, true).await + if let Some(mr) = search_provider( + manager, + track_info, + provider, + routeplanner.clone(), + cfg, + true, + ) + .await { tracing::info!( "[Mirror] throttled match \"{}\" via {} (score {:.3})", @@ -252,7 +263,7 @@ pub async fn resolve_scored( mr.provider, mr.score ); - return Some(mr.track); + return Ok(mr.track); } } @@ -263,7 +274,7 @@ pub async fn resolve_scored( best.provider, best.score ); - return Some(best.track); + return Ok(best.track); } tracing::warn!( @@ -271,7 +282,10 @@ pub async fn resolve_scored( track_info.title, track_info.author ); - None + Err(format!( + "No mirror found for track: {} - {}", + track_info.title, track_info.author + )) } async fn search_provider( @@ -329,7 +343,9 @@ async fn search_provider( 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 { + if let Some(track) = + super::resolver::resolve_nested_track(manager, id, routeplanner.clone()).await + { return Some(MirrorResult { track, score, diff --git a/src/sources/manager/mod.rs b/src/sources/manager/mod.rs index 408459b..c7f7d8a 100644 --- a/src/sources/manager/mod.rs +++ b/src/sources/manager/mod.rs @@ -5,9 +5,9 @@ use crate::{ sources::plugin::{BoxedSource, BoxedTrack}, }; +mod best_match; mod registration; mod resolver; -mod best_match; /// Source Manager handles the lifecycle and coordination of all audio sources. pub struct SourceManager { @@ -80,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 { @@ -97,7 +96,7 @@ impl SourceManager { ); if let Some(track) = source.get_track(identifier, routeplanner.clone()).await { - return Some(track); + return Ok(track); } break; } @@ -114,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 ef2bedd..78100b4 100644 --- a/src/sources/manager/resolver.rs +++ b/src/sources/manager/resolver.rs @@ -9,9 +9,16 @@ 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; + return super::best_match::resolve_scored( + manager, + track_info, + identifier, + mirrors, + routeplanner, + ) + .await; } let isrc = track_info.isrc.as_deref().unwrap_or(""); @@ -67,11 +74,19 @@ 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. @@ -81,11 +96,15 @@ pub async fn resolve_nested_track( 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 -} \ No newline at end of file +} From e41f3c2703ecd66652d29edd03b0aeece7c2a59a Mon Sep 17 00:00:00 2001 From: appujet Date: Tue, 10 Mar 2026 20:21:44 +0530 Subject: [PATCH 28/28] docs: replace detailed introduction with WIP placeholder --- docs/index.md | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/docs/index.md b/docs/index.md index 6070e71..88eba12 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,18 +1,4 @@ --- layout: doc --- -# Introduction - -Welcome to **Rustalink**, the blazing-fast, high-performance audio server written in Rust. - -Rustalink is designed from the ground up as a fully-featured, 1:1 drop-in replacement for Lavalink, allowing you to supercharge your existing Discord music bots without needing to rewrite your client code. - -## Why Choose Rustalink? - -- 🚀 **Zero-cost abstractions:** Built on Rust to guarantee memory safety and process audio at unparalleled speeds. -- 🤝 **100% Lavalink Compatible:** Plug and play with your current Lavalink wrappers and clients. You won't notice a difference in setup, but your users will notice the quality. -- 🪶 **Featherweight Footprint:** Exceptionally low CPU and RAM usage, enabling you to scale massive bots cheaply. -- 🎧 **High-Fidelity Audio:** By using advanced 32-bit floating point (`f32`) precision for all DSP effects, Rustalink entirely eliminates the audio tearing and quantization noise found in older integer-based processors. -- 🔌 **Fully Extensible:** Leverage our robust plugin architecture to inject custom audio sources, specialized route planners, or new REST API endpoints. - -Empower your Discord bot with the most reliable, modern, and high-performance audio engine available today. +# WIP \ No newline at end of file