diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 652dfd5..45105aa 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -25,4 +25,4 @@ Please delete options that are not relevant. - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have made corresponding changes to the documentation - [ ] I have added tests that prove my fix is effective or that my feature works -- [ ] I have checked my changes haven't lowered code coverage + diff --git a/.github/workflows/deploy_docs.yml b/.github/workflows/deploy_docs.yml index a8715b0..eab9c4f 100644 --- a/.github/workflows/deploy_docs.yml +++ b/.github/workflows/deploy_docs.yml @@ -26,17 +26,21 @@ jobs: ${{ runner.os }}-pubspec- - name: Install dependencies + working-directory: packages/nyxx_lavalink run: dart pub get - name: Generate docs + working-directory: packages/nyxx_lavalink run: dart doc - name: Extract branch name + working-directory: packages/nyxx_lavalink shell: bash run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" id: extract_branch - name: Deploy nyxx dev docs + working-directory: packages/nyxx_lavalink uses: easingthemes/ssh-deploy@v2.1.5 env: SSH_PRIVATE_KEY: ${{ secrets.DEPLOY_SSH_SERVER_KEY }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a4ed848..48a1ec9 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -6,8 +6,12 @@ on: - main jobs: - nyxx_publish: + publish: runs-on: ubuntu-latest + strategy: + matrix: + package: [lavalink, nyxx_lavalink] + steps: - name: Checkout uses: actions/checkout@v2 @@ -20,9 +24,10 @@ jobs: restore-keys: | ${{ runner.os }}-pubspec- - - name: 'publish nyxx package to pub.dev' + - name: 'publish ${{ matrix.package }} package to pub.dev' id: publish uses: k-paxian/dart-package-publisher@master + working-directory: packages/${{ matrix.package }} with: skipTests: true force: true diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index ece288b..ee0d4b1 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -11,6 +11,10 @@ jobs: runs-on: ubuntu-latest env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + strategy: + matrix: + package: [lavalink, nyxx_lavalink] + steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -27,9 +31,11 @@ jobs: ${{ runner.os }}-pubspec- - name: Install dependencies + working-directory: packages/${{ matrix.package }} run: dart pub get - name: Analyze project source + working-directory: packages/${{ matrix.package }} run: dart analyze format: @@ -37,6 +43,10 @@ jobs: runs-on: ubuntu-latest env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + strategy: + matrix: + package: [lavalink, nyxx_lavalink] + steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -53,9 +63,11 @@ jobs: ${{ runner.os }}-pubspec- - name: Install dependencies + working-directory: packages/${{ matrix.package }} run: dart pub get - name: Format + working-directory: packages/${{ matrix.package }} run: dart format --set-exit-if-changed -l 160 ./lib tests: @@ -64,6 +76,10 @@ jobs: runs-on: ubuntu-latest env: TEST_TOKEN: ${{ secrets.TEST_TOKEN }} + strategy: + matrix: + package: [lavalink, nyxx_lavalink] + steps: - name: Setup Dart Action uses: dart-lang/setup-dart@v1 @@ -80,7 +96,9 @@ jobs: ${{ runner.os }}-pubspec- - name: Install dependencies + working-directory: packages/${{ matrix.package }} run: dart pub get - name: Unit tests + working-directory: packages/${{ matrix.package }} run: dart run test test/unit/** diff --git a/.gitignore b/.gitignore index 295e586..63a682b 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,6 @@ docs/ .project .pub **/build -**/packages *.dart.js *.part.js *.js.deps @@ -37,3 +36,4 @@ test-*.dart coverage.json lcov.info pubspec.lock +application.yml.example diff --git a/Makefile b/Makefile deleted file mode 100644 index 8b96ec0..0000000 --- a/Makefile +++ /dev/null @@ -1,40 +0,0 @@ -.PHONY: help -help: - @fgrep -h "##" $(MAKEFILE_LIST) | sed -e 's/\(\:.*\#\#\)/\:\ /' | fgrep -v fgrep | sed -e 's/\\$$//' | sed -e 's/##//' - -.PHONY: app-check ## Run basic format checks and then generate code coverage -app-check: format-check generate-coverage - -.PHONY: format-check ## Check basic format -format-check: format analyze - -.PHONY: generate-coverage -generate-coverage: integration-tests unit-tests coverage-format coverage-gen-html ## Run all test and generate html code coverage - -.PHONY: integration-tests -integration-tests: ## Run integration tests with coverage - (timeout 20s dart run test --coverage="coverage" --timeout=none test/integration ; exit 0) - -.PHONY: unit-tests -unit-tests: ## Run unit tests with coverage - (timeout 10s dart run test --coverage="coverage" --timeout=none test/unit ; exit 0) - -.PHONY: coverage-format -coverage-format: ## Format dart coverage output to lcov - dart run coverage:format_coverage --lcov --in=coverage --out=coverage/coverage.lcov --packages=.packages --report-on=lib - -.PHONY: coverage-gen-html -coverage-gen-html: ## Generate html coverage from lcov data - genhtml coverage/coverage.lcov -o coverage/coverage_gen - -.PHONY: format -format: ## Run dart format - dart format --set-exit-if-changed -l 160 ./lib - -.PHONY: format-apply -format-apply: ## Run dart format - dart format --fix -l 160 ./lib - -.PHONY: analyze -analyze: ## Run dart analyze - dart analyze diff --git a/README.md b/README.md deleted file mode 100644 index 32bac54..0000000 --- a/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# nyxx_lavalink - -[![Discord Shield](https://discordapp.com/api/guilds/846136758470443069/widget.png?style=shield)](https://discord.gg/nyxx) -[![pub](https://img.shields.io/pub/v/nyxx.svg)](https://pub.dartlang.org/packages/nyxx_lavalink) -[![documentation](https://img.shields.io/badge/Documentation-nyxx_interactions-yellow.svg)](https://www.dartdocs.org/documentation/nyxx_lavalink/latest/) - -Simple, robust framework for creating discord bots for Dart language. - -
- -### Features - -- **Lavalink support**
- Nyxx allows you to create music bots by adding support to [Lavalink](https://github.com/freyacodes/Lavalink) API -- **Fine Control**
- Nyxx allows you to control every outgoing HTTP request or WebSocket message. - -## Quick example - -Lavalink -```dart -void main() async { - final bot = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged); - - final guildId = Snowflake("GUILD_ID"); - final channelId = Snowflake("CHANNEL_ID"); - - final cluster = ICluster.createCluster(bot, Snowflake("BOT_ID")); - await cluster.addNode(NodeOptions()); - - bot.eventsWs.onMessageReceived.listen((event) async { - if (event.message.content == "!join") { - final channel = await bot.fetchChannel(channelId); - - cluster.getOrCreatePlayerNode(guildId); - - channel.connect(); - } else { - final node = cluster.getOrCreatePlayerNode(guildId); - - final searchResults = await node.searchTracks(event.message.content); - - node.play(guildId, searchResults.tracks[0]).queue(); - } - }); -} -``` - -## Other nyxx packages - -- [nyxx](https://github.com/nyxx-discord/nyxx) -- [nyxx_interactions](https://github.com/nyxx-discord/nyxx_interactions) -- [nyxx_extensions](https://github.com/nyxx-discord/nyxx_extensions) -- [nyxx_commander](https://github.com/nyxx-discord/nyxx_commander) -- [nyxx_pagination](https://github.com/nyxx-discord/nyxx_pagination) - -## More examples - -Nyxx examples can be found [here](https://github.com/nyxx-discord/nyxx_lavalink/tree/dev/example). - -### Example bots -- [Running on Dart](https://github.com/l7ssha/running_on_dart) - -## Documentation, help and examples - -**Dartdoc documentation for latest stable version is hosted on [pub](https://www.dartdocs.org/documentation/nyxx_lavalink/latest/)** - -#### [Docs and wiki](https://nyxx.l7ssha.xyz) -You can read docs and wiki articles for latest stable version on my website. This website also hosts docs for latest -dev changes to framework (`dev` branch) - -#### [Official nyxx discord server](https://discord.gg/nyxx) -If you need assistance in developing bot using nyxx you can join official nyxx discord guild. - -#### [Discord API docs](https://discordapp.com/developers/docs/intro) -Discord API documentation features rich descriptions about all topics that nyxx covers. - -#### [Discord API Guild](https://discord.gg/discord-api) -The unofficial guild for Discord Bot developers. To get help with nyxx check `#dart_nyxx` channel. - -#### [Dartdocs](https://www.dartdocs.org/documentation/nyxx_lavalink/latest/) -The dartdocs page will always have the documentation for the latest release. - -## Contributing to Nyxx - -Read [contributing document](https://github.com/nyxx-discord/nyxx_lavalink/blob/dev/CONTRIBUTING.md) - -## Credits - -* [Hackzzila's](https://github.com/Hackzzila) for [nyx](https://github.com/Hackzzila/nyx). diff --git a/README.md b/README.md new file mode 120000 index 0000000..3978739 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +packages/nyxx_lavalink/README.md \ No newline at end of file diff --git a/analysis_options.yaml b/analysis_options.yaml deleted file mode 100644 index 53ec678..0000000 --- a/analysis_options.yaml +++ /dev/null @@ -1,13 +0,0 @@ -include: package:lints/recommended.yaml - -linter: - rules: - unrelated_type_equality_checks: false - implementation_imports: false - -analyzer: - exclude: [build/**, example/**] - language: - strict-raw-types: true - strong-mode: - implicit-casts: false diff --git a/example/example.dart b/example/example.dart deleted file mode 100644 index a25b37a..0000000 --- a/example/example.dart +++ /dev/null @@ -1,63 +0,0 @@ -import "dart:io"; - -import "package:nyxx_lavalink/nyxx_lavalink.dart"; -import "package:nyxx/nyxx.dart"; - -void main() async { - final client = NyxxFactory.createNyxxWebsocket("", GatewayIntents.allUnprivileged) - ..registerPlugin(Logging()) // Default logging plugin - ..registerPlugin(CliIntegration()) // Cli integration for nyxx allows stopping application via SIGTERM and SIGKILl - ..registerPlugin(IgnoreExceptions()) // Plugin that handles uncaught exceptions that may occur - ..connect(); - - final cluster = ICluster.createCluster(client, Snowflake("YOUR_BOT_ID")); - - // This is a really simple example, so we'll define the guild and - // the channel where the bot will play music on - final guildId = Snowflake("GUILD_ID_HERE"); - final channelId = Snowflake("CHANNEL_ID_HERE"); - - // Add your lava link nodes. Empty constructor assumes default settings to lavalink. - await cluster.addNode(NodeOptions()); - - await for (final msg in client.eventsWs.onMessageReceived) { - if(msg.message.content == "!join") { - final channel = await client.fetchChannel(channelId); - - // Create lava link node for guild - cluster.getOrCreatePlayerNode(guildId); - - // Connect to channel - channel.connect(); - } else if(msg.message.content == "!queue") { - // Fetch node for guild - final node = cluster.getOrCreatePlayerNode(guildId); - - // get player for guild - final player = node.players[guildId]; - - print(player!.queue); - } else if (msg.message.content == "!skip") { - final node = cluster.getOrCreatePlayerNode(guildId); - - // skip the current track, if it's the last on the queue, the - // player will stop automatically - node.skip(guildId); - } else if(msg.message.content == "!nodes") { - print("${cluster.connectedNodes.length} available nodes"); - } else if (msg.message.content == "!update") { - final node = cluster.getOrCreatePlayerNode(guildId); - - node.updateOptions(NodeOptions()); - } else { - // Any other message will be processed as potential title to play lava link - final node = cluster.getOrCreatePlayerNode(guildId); - - // search for given query using lava link - final searchResults = await node.searchTracks(msg.message.content); - - // add found song to queue and play - node.play(guildId, searchResults.tracks[0]).queue(); - } - } -} diff --git a/lib/nyxx_lavalink.dart b/lib/nyxx_lavalink.dart deleted file mode 100644 index c8eb41b..0000000 --- a/lib/nyxx_lavalink.dart +++ /dev/null @@ -1,22 +0,0 @@ -library nyxx_lavalink; - -export 'src/model/base_event.dart' show IBaseEvent; -export 'src/model/exception.dart' show ILavalinkException; -export 'src/model/guild_player.dart' show IGuildPlayer; -export 'src/model/play_parameters.dart' show IPlayParameters; -export 'src/model/player_update.dart' show IPlayerUpdateEvent, IPlayerUpdateStateEvent; -export 'src/model/search_platform.dart' show SearchPlatform; -export 'src/model/stats.dart' show IStatsEvent, ICpuStats, IFrameStats, IMemoryStats; -export 'src/model/track.dart' show IQueuedTrack, IPlaylistInfo, ITrack, ITrackInfo, ITracks; -export 'src/model/track_end.dart' show ITrackEndEvent; -export 'src/model/track_exception.dart' show ITrackExceptionEvent; -export 'src/model/track_start.dart' show ITrackStartEvent; -export 'src/model/track_stuck.dart' show ITrackStuckEvent; -export 'src/model/websocket_closed.dart' show IWebSocketClosedEvent; -export 'src/node/node.dart' show INode; -export 'src/node/node_options.dart' show NodeOptions; - -export 'src/cluster.dart' show ICluster; -export 'src/cluster_exception.dart' show ClusterException; -export 'src/event_dispatcher.dart' show IEventDispatcher; -export 'src/http_exception.dart' show HttpException; diff --git a/lib/src/cluster.dart b/lib/src/cluster.dart deleted file mode 100644 index 8f1e1cc..0000000 --- a/lib/src/cluster.dart +++ /dev/null @@ -1,298 +0,0 @@ -import 'dart:collection'; -import 'dart:isolate'; - -import 'package:logging/logging.dart'; -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; -import 'package:nyxx_lavalink/src/node/node_options.dart'; -import 'package:nyxx_lavalink/src/node/node_runner.dart'; -import 'package:nyxx_lavalink/src/model/guild_player.dart'; -import 'package:nyxx_lavalink/src/cluster_exception.dart'; - -import 'event_dispatcher.dart'; - -abstract class ICluster implements Disposable { - /// A reference to the client - INyxx get client; - - /// The client id provided to lavalink; - Snowflake get clientId; - - /// Returns a map with the nodes connected to lavalink cluster - UnmodifiableMapView get connectedNodes; - - /// Returns a map with the nodes that are actually disconnected from lavalink - UnmodifiableMapView get disconnectedNodes; - - /// Dispatcher of all lavalink events - late final IEventDispatcher eventDispatcher; - - /// Get the best available node, it is recommended to use [getOrCreatePlayerNode] instead - /// as this won't create the player itself if it doesn't exists - INode get bestNode; - - /// Attempts to get the node containing a player for a specific guild id - /// - /// if the player doesn't exist, then the best node is retrieved and the player created - INode getOrCreatePlayerNode(Snowflake guildId); - - /// Attempts to retrieve a node disconnected from lavalink by its id, - /// this method does not work with nodes that have exceeded the maximum - /// reconnect attempts as those get removed from cluster - INode? getDisconnectedNode(int nodeId); - - /// Adds and initializes a node - Future addNode(NodeOptions options); - - static ICluster createCluster(INyxxWebsocket client, Snowflake clientId) => Cluster(client, clientId); -} - -/// Cluster of lavalink nodes -class Cluster implements ICluster { - /// A reference to the client - @override - final INyxxWebsocket client; - - /// The client id provided to lavalink; - @override - final Snowflake clientId; - - /// All available nodes, ordered by node id - final Map nodes = {}; - - /// Returns a map with the nodes connected to lavalink cluster - @override - UnmodifiableMapView get connectedNodes => UnmodifiableMapView(nodes); - - /// Nodes that are currently connecting to server, when a node gets connected - /// it will be moved to [_nodes], and when reconnecting will be moved here again - final Map connectingNodes = {}; - - /// Returns a map with the nodes that are actually disconnected from lavalink - @override - UnmodifiableMapView get disconnectedNodes => UnmodifiableMapView(connectingNodes); - - /// A map to keep the assigned node id for each player - final Map nodeLocations = {}; - - /// The last id assigned to a node, this is used to avoid repeating ids - /// since if we use a repeated id, the existing node would be overwritten and lost - int _lastId = 0; - - final _receivePort = ReceivePort(); - late final Stream _receiveStream; - - final logger = Logger("Lavalink"); - - @override - late final IEventDispatcher eventDispatcher; - - Future _addNode(NodeOptions nodeOptions, int nodeId) async { - await Isolate.spawn(handleNode, _receivePort.sendPort); - - final isolateSendPort = await _receiveStream.firstWhere((element) => element is SendPort) as SendPort; - - nodeOptions.clientId = clientId; - nodeOptions.nodeId = nodeId; - - isolateSendPort.send(nodeOptions.toJson()); - - // Say the node to start the connection - isolateSendPort.send({"cmd": "CONNECT"}); - - final node = Node.fromOptions(this, nodeOptions, isolateSendPort); - - connectingNodes[nodeId] = node; - } - - void _handleNodeMessage(dynamic message) { - if (message is SendPort) { - return; - } - - final map = message as Map; - - logger.finer("Receved data from node ${map["nodeId"]}, data: $map"); - - switch (map["cmd"]) { - case "DISPATCH": - (eventDispatcher as EventDispatcher).dispatchEvent(map); - break; - - case "LOG": - { - Level? level; - - switch (map["level"]) { - case "INFO": - level = Level.INFO; - break; - - case "WARNING": - level = Level.WARNING; - break; - } - - logger.log(level!, map["message"]); - } - break; - - case "EXITED": - { - final nodeId = map["nodeId"]! as int; - nodes.remove(nodeId); - connectingNodes.remove(nodeId); - - logger.info("[Node $nodeId] Exited"); - } - break; - - case "CONNECTED": - { - final node = connectingNodes.remove(map["nodeId"] as int); - - if (node != null) { - nodes[node.options.nodeId] = node; - - logger.info("[Node ${map["nodeId"]}] Connected to lavalink"); - } - } - break; - - case "DISCONNECTED": - { - final node = nodes.remove(map["nodeId"] as int); - - if (node == null) { - return; - } - connectingNodes[node.options.nodeId] = node; - - // this makes possible for a player to be moved to another node - node.players.forEach((guildId, _) => nodeLocations.remove(guildId)); - - // Also delete the players, so them can be created again on another node - node.clearPlayers(); - - logger.info("[Node ${map["nodeId"]}] Disconnected from lavalink"); - } - break; - } - } - - void _registerEvents() { - client.eventsWs.onVoiceServerUpdate.listen((event) async { - final node = nodes[nodeLocations[event.guild.id]]; - if (node == null) { - return; - } - - final player = node.players[event.guild.id]; - if (player == null) { - return; - } - - (player as GuildPlayer).handleServerUpdate(event); - }); - - client.eventsWs.onVoiceStateUpdate.listen((event) async { - if (event.raw["d"]["user_id"] != clientId.toString()) { - return; - } - if (event.state.guild == null) { - return; - } - - final node = nodes[nodeLocations[event.state.guild!.id]]; - if (node == null) { - return; - } - - final player = node.players[event.state.guild!.id]; - if (player == null) { - return; - } - - (player as GuildPlayer).handleStateUpdate(event); - }); - } - - /// Get the best available node, it is recommended to use [getOrCreatePlayerNode] instead - /// as this won't create the player itself if it doesn't exists - @override - INode get bestNode { - if (nodes.isEmpty) { - throw ClusterException("No available nodes"); - } - if (nodes.length == 1) { - return nodes.values.first; - } - - /// Node id of the node who has fewer players - int? minNodeId; - - /// Number of players the node has - int? minNodePlayers; - - nodes.forEach((id, node) { - if (minNodeId == null && minNodePlayers == null) { - minNodeId = id; - minNodePlayers = node.players.length; - } else { - if (node.players.length < minNodePlayers!) { - minNodeId = id; - minNodePlayers = node.players.length; - } - } - }); - - return nodes[minNodeId]!; - } - - /// Attempts to get the node containing a player for a specific guild id - /// - /// if the player doesn't exist, then the best node is retrieved and the player created - @override - INode getOrCreatePlayerNode(Snowflake guildId) { - final nodePreview = nodeLocations.containsKey(guildId) ? nodes[nodeLocations[guildId]] : bestNode; - - final node = nodePreview ?? bestNode; - - if (!node.players.containsKey(guildId)) { - node.createPlayer(guildId); - } - - return node; - } - - /// Attempts to retrieve a node disconnected from lavalink by its id, - /// this method does not work with nodes that have exceeded the maximum - /// reconnect attempts as those get removed from cluster - @override - INode? getDisconnectedNode(int nodeId) => connectingNodes[nodeId]; - - /// Adds and initializes a node - @override - Future addNode(NodeOptions options) async { - /// Set a tiny delay so we can ensure we don't repeat ids - await Future.delayed(const Duration(milliseconds: 50)); - - _lastId += 1; - await _addNode(options, _lastId); - } - - /// Creates a new cluster ready to start adding connections - Cluster(this.client, this.clientId) { - _registerEvents(); - - eventDispatcher = EventDispatcher(this); - - _receiveStream = _receivePort.asBroadcastStream(); - _receiveStream.listen(_handleNodeMessage); - } - - @override - Future dispose() async { - await eventDispatcher.dispose(); - } -} diff --git a/lib/src/cluster_exception.dart b/lib/src/cluster_exception.dart deleted file mode 100644 index 33cae47..0000000 --- a/lib/src/cluster_exception.dart +++ /dev/null @@ -1,10 +0,0 @@ -/// An exception related to cluster functions -class ClusterException implements Exception { - /// The actual error description - final String error; - - ClusterException(this.error); - - @override - String toString() => "Lavalink cluster error: $error"; -} diff --git a/lib/src/event_dispatcher.dart b/lib/src/event_dispatcher.dart deleted file mode 100644 index 0efed4a..0000000 --- a/lib/src/event_dispatcher.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:async'; - -import 'package:nyxx/nyxx.dart'; -import 'cluster.dart'; - -import 'model/stats.dart'; -import 'model/track.dart'; -import 'model/track_end.dart'; -import 'model/track_start.dart'; -import 'model/track_stuck.dart'; -import 'model/track_exception.dart'; -import 'model/player_update.dart'; -import 'model/websocket_closed.dart'; -import 'node/node.dart'; - -abstract class IEventDispatcher implements Disposable { - /// Emitted when stats are sent from lavalink - Stream get onStatsReceived; - - /// Emitted when a player gets updated - Stream get onPlayerUpdate; - - /// Emitted when a track starts playing - Stream get onTrackStart; - - /// Emitted when a track ends playing - Stream get onTrackEnd; - - /// Emitted when a track gets an exception during playback - Stream get onTrackException; - - /// Emitted when a track gets stuck - Stream get onTrackStuck; - - /// Emitted when a web socket is closed - Stream get onWebSocketClosed; -} - -class EventDispatcher implements IEventDispatcher { - final Cluster cluster; - - final StreamController onStatsReceivedController = StreamController.broadcast(); - final StreamController onPlayerUpdateController = StreamController.broadcast(); - final StreamController onTrackStartController = StreamController.broadcast(); - final StreamController onTrackEndController = StreamController.broadcast(); - final StreamController onTrackExceptionController = StreamController.broadcast(); - final StreamController onTrackStuckController = StreamController.broadcast(); - final StreamController onWebSocketClosedController = StreamController.broadcast(); - - /// Emitted when stats are sent from lavalink - @override - late final Stream onStatsReceived; - - /// Emitted when a player gets updated - @override - late final Stream onPlayerUpdate; - - /// Emitted when a track starts playing - @override - late final Stream onTrackStart; - - /// Emitted when a track ends playing - @override - late final Stream onTrackEnd; - - /// Emitted when a track gets an exception during playback - @override - late final Stream onTrackException; - - /// Emitted when a track gets stuck - @override - late final Stream onTrackStuck; - - /// Emitted when a web socket is closed - @override - late final Stream onWebSocketClosed; - - EventDispatcher(this.cluster) { - onStatsReceived = onStatsReceivedController.stream; - onPlayerUpdate = onPlayerUpdateController.stream; - onTrackStart = onTrackStartController.stream; - onTrackEnd = onTrackEndController.stream; - onTrackException = onTrackExceptionController.stream; - onTrackStuck = onTrackStuckController.stream; - onWebSocketClosed = onWebSocketClosedController.stream; - } - - void dispatchEvent(Map json) { - final node = cluster.nodes[json["nodeId"]]; - - if (node == null) { - return; - } - - cluster.logger.fine("[Node ${json["nodeId"]}] Dispatching ${json["event"]}"); - - switch (json["event"]) { - case "TrackStartEvent": - onTrackStartController.add(TrackStartEvent(cluster.client, node, json["data"] as Map)); - break; - - case "TrackEndEvent": - { - final trackEnd = TrackEndEvent(cluster.client, node, json["data"] as Map); - - onTrackEndController.add(trackEnd); - - (node as Node).handleTrackEnd(trackEnd); - } - break; - - case "TrackExceptionEvent": - onTrackExceptionController.add(TrackExceptionEvent(cluster.client, node, json["data"] as Map)); - break; - - case "TrackStuckEvent": - onTrackStuckController.add(TrackStuckEvent(cluster.client, node, json["data"] as Map)); - break; - - case "WebSocketClosedEvent": - onWebSocketClosedController.add(WebSocketClosedEvent(cluster.client, node, json["data"] as Map)); - break; - - case "stats": - final stats = StatsEvent(cluster.client, node, json["data"] as Map); - - // Put the stats into the node - (node as Node).stats = stats; - - onStatsReceivedController.add(stats); - break; - - case "playerUpdate": - final update = PlayerUpdateEvent(cluster.client, node, json["data"] as Map); - - if (update.state.position != null) { - final _node = node as Node; - // Update the position of the currently playing track of the corresponding player. - (_node.players[update.guildId]?.nowPlaying?.track.info as TrackInfo?)?.position = update.state.position!; - } - - onPlayerUpdateController.add(update); - break; - } - } - - @override - Future dispose() async { - await onStatsReceivedController.close(); - await onPlayerUpdateController.close(); - await onTrackStartController.close(); - await onTrackEndController.close(); - await onWebSocketClosedController.close(); - } -} diff --git a/lib/src/http_exception.dart b/lib/src/http_exception.dart deleted file mode 100644 index 7adf4cd..0000000 --- a/lib/src/http_exception.dart +++ /dev/null @@ -1,11 +0,0 @@ -/// An exception that can be thrown when using -/// [Node.searchTracks] or [Node.autoSearch] if the request fails -class HttpException implements Exception { - /// The status code of the request - final int code; - - HttpException(this.code); - - @override - String toString() => "Lavalink server responded with $code code"; -} diff --git a/lib/src/model/base_event.dart b/lib/src/model/base_event.dart deleted file mode 100644 index 2cadbb8..0000000 --- a/lib/src/model/base_event.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class IBaseEvent { - /// A reference to the current client - INyxx get client; - - /// A reference to the node this event belongs to - INode get node; -} - -/// Base event class which all events must inherit -class BaseEvent implements IBaseEvent { - /// A reference to the current client - @override - final INyxx client; - - /// A reference to the node this event belongs to - @override - final INode node; - - /// Creates a new base event instance - BaseEvent(this.client, this.node); -} diff --git a/lib/src/model/exception.dart b/lib/src/model/exception.dart deleted file mode 100644 index f3a72e5..0000000 --- a/lib/src/model/exception.dart +++ /dev/null @@ -1,43 +0,0 @@ -/// A exception object that can be sent by lavalink at certain endpoints -abstract class ILavalinkException { - /// Exception message - String? get message; - - /// The error message - String? get error; - - /// The cause of the exception - String? get cause; - - /// Exception severity - String? get severity; -} - -/// A exception object that can be sent by lavalink at certain endpoints -class LavalinkException implements ILavalinkException { - /// Exception message - @override - late final String? message; - - /// The error message - @override - late final String? error; - - /// The cause of the exception - @override - late final String? cause; - - /// Exception severity - @override - late final String? severity; - - LavalinkException(Map json) { - if (json.containsKey("exception")) { - message = json["exception"]["message"] as String?; - severity = json["exception"]["severity"] as String?; - cause = json["exception"]["cause"] as String?; - } else { - error = json["error"] as String?; - } - } -} diff --git a/lib/src/model/guild_player.dart b/lib/src/model/guild_player.dart deleted file mode 100644 index d5c7f87..0000000 --- a/lib/src/model/guild_player.dart +++ /dev/null @@ -1,56 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -import 'package:nyxx_lavalink/src/model/track.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class IGuildPlayer { - /// Track queue - List get queue; - - /// The currently playing track - IQueuedTrack? get nowPlaying; - - /// Guild where this player operates on - Snowflake get guildId; -} - -/// A player of a specific guild -class GuildPlayer implements IGuildPlayer { - /// Track queue - @override - List queue = []; - - /// The currently playing track - @override - QueuedTrack? nowPlaying; - - /// Guild where this player operates on - @override - final Snowflake guildId; - - /// A map to combine server state and server update events to send them to lavalink - final Map _serverUpdate = {}; - - /// A reference to the parent node - final INode _nodeRef; - - GuildPlayer(this._nodeRef, this.guildId); - - void _dispatchVoiceUpdate() { - if (_serverUpdate.containsKey("sessionId") && _serverUpdate.containsKey("event")) { - (_nodeRef as Node).sendPayload("voiceUpdate", guildId, _serverUpdate); - } - } - - void handleServerUpdate(IVoiceServerUpdateEvent event) { - _serverUpdate["event"] = {"token": event.token, "endpoint": event.endpoint, "guildId": guildId.toString()}; - - _dispatchVoiceUpdate(); - } - - void handleStateUpdate(IVoiceStateUpdateEvent event) { - _serverUpdate["sessionId"] = event.state.sessionId; - - _dispatchVoiceUpdate(); - } -} diff --git a/lib/src/model/play_parameters.dart b/lib/src/model/play_parameters.dart deleted file mode 100644 index 0fe7639..0000000 --- a/lib/src/model/play_parameters.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_lavalink/src/model/track.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class IPlayParameters { - /// The track to play - ITrack get track; - - /// The guild where the track will be played - Snowflake get guildId; - - /// Whether to replace the track or not - bool get replace; - - /// The time at where the track will start to play - Duration get startTime; - - /// The time at where the track will stop playing - Duration? get endTime; - - /// The requester of the track - Snowflake? get requester; - - /// The channel where this track was requested - Snowflake? get channelId; - - /// Forces the song to start playing - void startPlaying(); - - /// Puts the track on the queue and starts playing if necessary - void queue(); -} - -/// Parameters to start playing a track -class PlayParameters implements IPlayParameters { - final INode _node; - - /// The track to play - @override - final ITrack track; - - /// The guild where the track will be played - @override - final Snowflake guildId; - - /// Whether to replace the track or not - @override - bool replace; - - /// The time at where the track will start to play - @override - Duration startTime; - - /// The time at where the track will stop playing - @override - Duration? endTime; - - /// The requester of the track - @override - Snowflake? requester; - - /// The channel where this track was requested - @override - Snowflake? channelId; - - /// Create a new play parameters object, it is recommended to create this - /// through [Node.play] - PlayParameters(this._node, this.track, this.guildId, this.replace, this.startTime, this.endTime, this.requester, this.channelId); - - /// Forces the song to start playing - @override - void startPlaying() { - if (endTime == null) { - (_node as Node).sendPayload("play", guildId, {"track": track.track, "noReplace": !replace, "startTime": startTime.inMilliseconds}); - - return; - } - - (_node as Node).sendPayload("play", guildId, {"track": track.track, "noReplace": !replace, "startTime": startTime, "endTime": endTime!.inMilliseconds}); - } - - /// Puts the track on the queue and starts playing if necessary - @override - void queue() { - final player = (_node as Node).players[guildId]; - - if (player == null) { - return; - } - - final queuedTrack = QueuedTrack(track, startTime, endTime, requester, channelId); - - // Whether if the node should start playing the track - final shouldPlay = player.nowPlaying == null && player.queue.isEmpty; - player.queue.add(queuedTrack); - - if (shouldPlay) { - (_node as Node).playNext(guildId); - } - } -} diff --git a/lib/src/model/player_update.dart b/lib/src/model/player_update.dart deleted file mode 100644 index 16b9309..0000000 --- a/lib/src/model/player_update.dart +++ /dev/null @@ -1,48 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_lavalink/src/model/base_event.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class IPlayerUpdateEvent implements IBaseEvent { - /// State of the current player - IPlayerUpdateStateEvent get state; - - /// Guild id where player comes from - Snowflake get guildId; -} - -/// Player update event dispatched by lavalink at player progression -class PlayerUpdateEvent extends BaseEvent implements IPlayerUpdateEvent { - /// State of the current player - @override - late final IPlayerUpdateStateEvent state; - - /// Guild id where player comes from - @override - late final Snowflake guildId; - - PlayerUpdateEvent(INyxx client, INode node, Map json) : super(client, node) { - guildId = Snowflake(json["guildId"]); - state = PlayerUpdateStateEvent(json["state"]["time"] as int, json["state"]["position"] as int?); - } -} - -abstract class IPlayerUpdateStateEvent { - /// The timestamp of the player - int get time; - - /// The position where the current track is now on - int? get position; -} - -/// The state of a player at a given moment -class PlayerUpdateStateEvent implements IPlayerUpdateStateEvent { - /// The timestamp of the player - @override - final int time; - - /// The position where the current track is now on - @override - final int? position; - - PlayerUpdateStateEvent(this.time, this.position); -} diff --git a/lib/src/model/search_platform.dart b/lib/src/model/search_platform.dart deleted file mode 100644 index d944d19..0000000 --- a/lib/src/model/search_platform.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -/// Search platforms supported by Lavalink -class SearchPlatform extends IEnum { - /// Youtube - static const youtube = SearchPlatform._create("ytsearch"); - - /// Youtube Music - static const youtubeMusic = SearchPlatform._create("ytmsearch"); - - /// SoundCloud - static const soundcloud = SearchPlatform._create("scsearch"); - - const SearchPlatform._create(String value) : super(value); -} diff --git a/lib/src/model/stats.dart b/lib/src/model/stats.dart deleted file mode 100644 index be485c4..0000000 --- a/lib/src/model/stats.dart +++ /dev/null @@ -1,164 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -import 'package:nyxx_lavalink/src/model/base_event.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class IStatsEvent implements IBaseEvent { - /// Number of playing players - int get playingPlayers; - - ///Memory usage stats - IMemoryStats get memory; - - /// Frame sending stats - IFrameStats? get frameStats; - - /// Total amount of players - int get players; - - /// Cpu usage stats - ICpuStats get cpu; - - /// Server uptime - int get uptime; -} - -/// Stats update event dispatched by lavalink -class StatsEvent extends BaseEvent implements IStatsEvent { - /// Number of playing players - @override - late final int playingPlayers; - - ///Memory usage stats - @override - late final IMemoryStats memory; - - /// Frame sending stats - @override - late final IFrameStats? frameStats; - - /// Total amount of players - @override - late final int players; - - /// Cpu usage stats - @override - late final ICpuStats cpu; - - /// Server uptime - @override - late final int uptime; - - StatsEvent(INyxx client, INode node, Map json) : super(client, node) { - playingPlayers = json["playingPlayers"] as int; - players = json["players"] as int; - uptime = json["uptime"] as int; - memory = MemoryStats(json["memory"] as Map); - frameStats = json["frameStats"] == null ? null : FrameStats(json["frameStats"] as Map); - cpu = CpuStats(json["cpu"] as Map); - } -} - -abstract class IFrameStats { - /// Sent frames - int get sent; - - /// Deficit frames - int get deficit; - - /// Nulled frames - int get nulled; -} - -/// Stats about frame sending to discord -class FrameStats implements IFrameStats { - /// Sent frames - @override - late final int sent; - - /// Deficit frames - @override - late final int deficit; - - /// Nulled frames - @override - late final int nulled; - - FrameStats(Map json) { - sent = json["sent"] as int; - deficit = json["deficit"] as int; - nulled = json["nulled"] as int; - } -} - -abstract class ICpuStats { - /// Amount of available cores on the cpu - int get cores; - - /// The total load of the machine where lavalink is running on - num get systemLoad; - - /// The total load of lavalink server - num get lavalinkLoad; -} - -/// Cpu usage stats -class CpuStats implements ICpuStats { - /// Amount of available cores on the cpu - @override - late final int cores; - - /// The total load of the machine where lavalink is running on - @override - late final num systemLoad; - - /// The total load of lavalink server - @override - late final num lavalinkLoad; - - CpuStats(Map json) { - cores = json["cores"] as int; - systemLoad = json["systemLoad"] as num; - lavalinkLoad = json["lavalinkLoad"] as num; - } -} - -abstract class IMemoryStats { - /// Reservable memory - int get reservable; - - /// Used memory - int get used; - - /// Free/unused memory - int get free; - - /// Total allocated memory - int get allocated; -} - -/// Memory usage stats -class MemoryStats implements IMemoryStats { - /// Reservable memory - @override - late final int reservable; - - /// Used memory - @override - late final int used; - - /// Free/unused memory - @override - late final int free; - - /// Total allocated memory - @override - late final int allocated; - - MemoryStats(Map json) { - reservable = json["reservable"] as int; - used = json["used"] as int; - free = json["free"] as int; - allocated = json["allocated"] as int; - } -} diff --git a/lib/src/model/track.dart b/lib/src/model/track.dart deleted file mode 100644 index 64a77fc..0000000 --- a/lib/src/model/track.dart +++ /dev/null @@ -1,212 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -import 'package:nyxx_lavalink/src/model/exception.dart'; - -abstract class IQueuedTrack { - /// The actual track - ITrack get track; - - /// Where should start lavalink playing the track - Duration get startTime; - - /// If the track should stop playing before finish and where - Duration? get endTime; - - /// The requester of the track - Snowflake? get requester; - - /// The channel where this track was requested - Snowflake? get channelId; -} - -/// Represents a track already on a player queue -class QueuedTrack implements IQueuedTrack { - /// The actual track - @override - final ITrack track; - - /// Where should start lavalink playing the track - @override - final Duration startTime; - - /// If the track should stop playing before finish and where - @override - final Duration? endTime; - - /// The requester of the track - @override - final Snowflake? requester; - - /// The channel where this track was requested - @override - final Snowflake? channelId; - - /// Create a new QueuedTrack instance - QueuedTrack(this.track, this.startTime, this.endTime, this.requester, this.channelId); -} - -abstract class ITrack { - /// Base64 encoded track - String get track; - - /// Optional information about the track - ITrackInfo? get info; -} - -/// Lavalink track object -class Track implements ITrack { - /// Base64 encoded track - @override - late final String track; - - /// Optional information about the track - @override - late final ITrackInfo? info; - - /// Create a new track instance - Track(this.track, this.info); - - Track.fromJson(Map json) { - if (json.containsKey("info")) { - info = TrackInfo(json["info"] as Map); - } - - track = json["track"] as String; - } -} - -abstract class ITrackInfo { - /// Track identifier - String get identifier; - - /// If the track is seekable (if it's a streaming it's not) - bool get seekable; - - /// The author of the track - String get author; - - /// The length of the track - int get length; - - /// Whether the track is a streaming or not - bool get stream; - - /// Position returned by lavalink - int get position; - - /// The title of the track - String get title; - - /// Url of the track - String get uri; -} - -/// Track details -class TrackInfo implements ITrackInfo { - /// Track identifier - @override - late final String identifier; - - /// If the track is seekable (if it's a streaming it's not) - @override - late final bool seekable; - - /// The author of the track - @override - late final String author; - - /// The length of the track - @override - late final int length; - - /// Whether the track is a streaming or not - @override - late final bool stream; - - /// Position returned by lavalink - @override - late int position; - - /// The title of the track - @override - late final String title; - - /// Url of the track - @override - late final String uri; - - TrackInfo(Map json) { - identifier = json["identifier"] as String; - seekable = json["isSeekable"] as bool; - author = json["author"] as String; - length = json["length"] as int; - stream = json["isStream"] as bool; - position = json["position"] as int; - title = json["title"] as String; - uri = json["uri"] as String; - } -} - -abstract class IPlaylistInfo { - /// Name of the playlist - String? get name; - - /// Currently selected track - int? get selectedTrack; -} - -/// Playlist info -class PlaylistInfo implements IPlaylistInfo { - /// Name of the playlist - @override - late final String? name; - - /// Currently selected track - @override - late final int? selectedTrack; - - PlaylistInfo(Map json) { - name = json["name"] as String?; - selectedTrack = json["selectedTrack"] as int?; - } -} - -abstract class ITracks { - /// Information about loaded playlist - IPlaylistInfo get playlistInfo; - - /// Load type (track, playlist, etc) - String get loadType; - - /// Loaded tracks - List get tracks; - - /// Occurred exception (if occurred) - ILavalinkException? get exception; -} - -/// Object returned from lavalink when searching -class Tracks implements ITracks { - /// Information about loaded playlist - @override - late final IPlaylistInfo playlistInfo; - - /// Load type (track, playlist, etc) - @override - late final String loadType; - - /// Loaded tracks - @override - late final List tracks; - - /// Occurred exception (if occurred) - @override - late final ILavalinkException? exception; - - Tracks(Map json) { - playlistInfo = PlaylistInfo(json["playlistInfo"] as Map); - loadType = json["loadType"] as String; - tracks = (json["tracks"] as List).map((t) => Track.fromJson(t as Map)).toList(); - exception = json["exception"] == null ? null : LavalinkException(json["exception"] as Map); - } -} diff --git a/lib/src/model/track_end.dart b/lib/src/model/track_end.dart deleted file mode 100644 index 212b3f1..0000000 --- a/lib/src/model/track_end.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_lavalink/src/model/base_event.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class ITrackEndEvent implements IBaseEvent { - /// Reason to the track to end - String get reason; - - /// Base64 encoded track - String get track; - - /// Guild where the track ended - Snowflake get guildId; -} - -/// Object sent when a track ends playing -class TrackEndEvent extends BaseEvent implements ITrackEndEvent { - /// Reason to the track to end - @override - late final String reason; - - /// Base64 encoded track - @override - late final String track; - - /// Guild where the track ended - @override - late final Snowflake guildId; - - TrackEndEvent(INyxx client, INode node, Map json) : super(client, node) { - reason = json["reason"] as String; - track = json["track"] as String; - guildId = Snowflake(json["guildId"] as String); - } -} diff --git a/lib/src/model/track_exception.dart b/lib/src/model/track_exception.dart deleted file mode 100644 index 1da6bc2..0000000 --- a/lib/src/model/track_exception.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_lavalink/src/model/base_event.dart'; -import 'package:nyxx_lavalink/src/model/exception.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class ITrackExceptionEvent implements IBaseEvent { - /// Base64 encoded track - String get track; - - /// The occurred error - ILavalinkException get exception; - - /// Guild id where the track got an exception - Snowflake get guildId; -} - -/// Object sent when a track gets an exception while playing -class TrackExceptionEvent extends BaseEvent implements ITrackExceptionEvent { - /// Base64 encoded track - @override - late final String track; - - /// The occurred error - @override - late final ILavalinkException exception; - - /// Guild id where the track got an exception - @override - late final Snowflake guildId; - - TrackExceptionEvent(INyxx client, INode node, Map json) : super(client, node) { - track = json["track"] as String; - exception = LavalinkException(json); - guildId = Snowflake(json["guildId"]); - } -} diff --git a/lib/src/model/track_start.dart b/lib/src/model/track_start.dart deleted file mode 100644 index 7b15eda..0000000 --- a/lib/src/model/track_start.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -import 'package:nyxx_lavalink/src/model/base_event.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class ITrackStartEvent implements IBaseEvent { - /// Track start type (if its replaced or not the track) - String get startType; - - /// Base64 encoded track - String get track; - - /// Guild where the track started - Snowflake get guildId; -} - -/// Object sent when a track starts playing -class TrackStartEvent extends BaseEvent implements ITrackStartEvent { - /// Track start type (if its replaced or not the track) - @override - late final String startType; - - /// Base64 encoded track - @override - late final String track; - - /// Guild where the track started - @override - late final Snowflake guildId; - - TrackStartEvent(INyxx client, INode node, Map json) : super(client, node) { - startType = json["type"] as String; - track = json["track"] as String; - guildId = Snowflake(json["guildId"]); - } -} diff --git a/lib/src/model/track_stuck.dart b/lib/src/model/track_stuck.dart deleted file mode 100644 index 281c4d0..0000000 --- a/lib/src/model/track_stuck.dart +++ /dev/null @@ -1,36 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -import 'package:nyxx_lavalink/src/model/base_event.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class ITrackStuckEvent implements IBaseEvent { - /// Base64 encoded track - String get track; - - /// The wait threshold that was exceeded for this event to trigger - int get thresholdMs; - - /// Guild where the track got stuck - Snowflake get guildId; -} - -/// Object sent when a track gets stuck when playing -class TrackStuckEvent extends BaseEvent implements ITrackStuckEvent { - /// Base64 encoded track - @override - late final String track; - - /// The wait threshold that was exceeded for this event to trigger - @override - late final int thresholdMs; - - /// Guild where the track got stuck - @override - late final Snowflake guildId; - - TrackStuckEvent(INyxx client, INode node, Map json) : super(client, node) { - track = json["track"] as String; - thresholdMs = json["thresholdMs"] as int; - guildId = Snowflake(json["guildId"] as String); - } -} diff --git a/lib/src/model/websocket_closed.dart b/lib/src/model/websocket_closed.dart deleted file mode 100644 index d95559a..0000000 --- a/lib/src/model/websocket_closed.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -import 'package:nyxx_lavalink/src/model/base_event.dart'; -import 'package:nyxx_lavalink/src/node/node.dart'; - -abstract class IWebSocketClosedEvent implements IBaseEvent { - /// Guild where the websocket has closed - Snowflake get guildId; - - /// Close code - int get code; - - /// Reason why the socket closed - String get reason; - - /// If the connection was closed by discord - bool get byRemote; -} - -/// Web socket closed event from lavalink -class WebSocketClosedEvent extends BaseEvent implements IWebSocketClosedEvent { - /// Guild where the websocket has closed - @override - late final Snowflake guildId; - - /// Close code - @override - late final int code; - - /// Reason why the socket closed - @override - late final String reason; - - /// If the connection was closed by discord - @override - late final bool byRemote; - - WebSocketClosedEvent(INyxx client, INode node, Map json) : super(client, node) { - guildId = Snowflake(json["guildId"]); - code = json["code"] as int; - reason = json["reason"] as String; - byRemote = json["byRemote"] as bool; - } -} diff --git a/lib/src/node/node.dart b/lib/src/node/node.dart deleted file mode 100644 index cadc38b..0000000 --- a/lib/src/node/node.dart +++ /dev/null @@ -1,344 +0,0 @@ -import 'dart:collection'; -import 'dart:convert'; -import 'dart:isolate'; - -import 'package:http/http.dart'; -import 'package:nyxx/nyxx.dart'; -import 'package:nyxx_lavalink/src/http_exception.dart'; -import 'package:nyxx_lavalink/src/model/play_parameters.dart'; -import 'package:nyxx_lavalink/src/model/search_platform.dart'; -import 'package:nyxx_lavalink/src/model/track_end.dart'; -import 'package:nyxx_lavalink/src/node/node_options.dart'; -import 'package:nyxx_lavalink/src/model/guild_player.dart'; -import 'package:nyxx_lavalink/src/model/stats.dart'; -import 'package:nyxx_lavalink/src/model/track.dart'; -import 'package:nyxx_lavalink/src/cluster.dart'; - -abstract class INode { - /// Node options, such as host, port, etc.. - NodeOptions get options; - - /// Returns a map with all the players the node currently has - UnmodifiableMapView get players; - - /// Returns the last stats received by this node - IStatsEvent? get stats; - - /// Destroys a player - void destroy(Snowflake guildId); - - /// Stops a player - void stop(Snowflake guildId); - - /// Skips a track, starting the next one if available or stopping the player if not - void skip(Snowflake guildId); - - /// Set the pause state of a player - /// - /// this method is internally used by [resume] and [pause] - void setPause(Snowflake guildId, bool pauseState); - - /// Seeks for a given time at the currently playing track - void seek(Snowflake guildId, Duration time); - - /// Sets the volume for a guild player, [volume] should be a number between 1 to 1000 - void volume(Snowflake guildId, int volume); - - /// Pauses a guild player - void pause(Snowflake guildId); - - /// Resumes the track playback of a guild player - void resume(Snowflake guildId); - - /// Clears all the players this node handles - void clearPlayers(); - - /// Searches a given query over the lavalink api and returns the results - Future searchTracks(String query); - - /// Searches a provided query on selected platform (YouTube by default), - /// if the query is a link it's searched directly by the link - Future autoSearch( - String query, { - SearchPlatform platform = SearchPlatform.youtube, - }); - - /// Get the [PlayParameters] object for a specific track - IPlayParameters play(Snowflake guildId, ITrack track, - {bool replace = false, Duration startTime = const Duration(), Duration? endTime, Snowflake? requester, Snowflake? channelId}); - - /// Shuts down the node - void shutdown(); - - /// Create a new player for a specific guild - IGuildPlayer createPlayer(Snowflake guildId); - - /// Updates the [NodeOptions] property of the node, also reconnects the - /// websocket to the new options - void updateOptions(NodeOptions newOptions); - - /// Tells the node to disconnect from lavalink server - void disconnect(); - - /// Tells the node to reconnect to lavalink server - void reconnect(); -} - -/// Represents an active and running lavalink node -class Node implements INode { - /// Node options, such as host, port, etc.. - @override - NodeOptions options; - - /// A map with guild ids as keys and players as values - final Map _players = {}; - - /// Returns a map with all the players the node currently has - @override - UnmodifiableMapView get players => UnmodifiableMapView(_players); - - /// Returns the last stats received by this node - @override - IStatsEvent? stats; - - /// Http client used with this node - final Client _httpClient = Client(); - - final SendPort _nodeSendPort; - late String _httpUri; - late Map _defaultHeaders; - final ICluster _cluster; - - /// A regular expression to avoid searching when a link is provided - final RegExp _urlRegex = RegExp(r"https?://(?:www\.)?.+"); - - /// Build a new Node - Node.fromOptions(this._cluster, this.options, this._nodeSendPort) { - _httpUri = options.ssl ? "https://${options.host}:${options.port}" : "http://${options.host}:${options.port}"; - - _defaultHeaders = {"Authorization": options.password, "Num-Shards": options.shards.toString(), "User-Id": options.clientId.toString()}; - } - - /// Clears all the players this node handles - @override - void clearPlayers() { - _players.clear(); - } - - void sendPayload(String op, Snowflake guildId, [Map? data]) async { - if (data == null) { - _nodeSendPort.send({ - "cmd": "SEND", - "data": { - "op": op, - "guildId": guildId.toString(), - } - }); - } else { - _nodeSendPort.send({ - "cmd": "SEND", - "data": {"op": op, "guildId": guildId.toString(), ...data} - }); - } - } - - void playNext(Snowflake guildId) async { - final player = _players[guildId]; - - if (player == null) { - return; - } - - final track = player.queue.first; - - player.nowPlaying = track; - - if (track.endTime == null) { - sendPayload("play", guildId, { - "track": track.track.track, - "noReplace": false, - "startTime": track.startTime.inMilliseconds, - }); - } else { - sendPayload("play", guildId, - {"track": track.track.track, "noReplace": false, "startTime": track.startTime.inMilliseconds, "endTime": track.endTime!.inMilliseconds}); - } - } - - void handleTrackEnd(ITrackEndEvent event) { - if (!(event.reason == "FINISHED")) { - return; - } - - final player = _players[event.guildId]; - - if (player == null) { - return; - } - - player.queue.removeAt(0); - player.nowPlaying = null; - - if (player.queue.isEmpty) { - return; - } - - playNext(event.guildId); - } - - /// Destroys a player - @override - void destroy(Snowflake guildId) { - sendPayload("destroy", guildId); - - // delete the actual player - _players.remove(guildId); - - // delete the relationship between this node and the player so - // if this guild creates a new player, it can be assigned to other node - (_cluster as Cluster).nodeLocations.remove(guildId); - } - - /// Stops a player - @override - void stop(Snowflake guildId) { - final player = _players[guildId]; - - if (player == null) { - return; - } - - player.queue.clear(); - player.nowPlaying = null; - - sendPayload("stop", guildId); - } - - /// Skips a track, starting the next one if available or stopping the player if not - @override - void skip(Snowflake guildId) { - final player = _players[guildId]; - - if (player == null) { - return; - } - - if (player.queue.isEmpty) { - return; - } else if (player.queue.length == 1) { - stop(guildId); - return; - } else { - player.queue.removeAt(0); - playNext(guildId); - } - } - - /// Set the pause state of a player - /// - /// this method is internally used by [resume] and [pause] - @override - void setPause(Snowflake guildId, bool pauseState) { - sendPayload("pause", guildId, {"pause": pauseState}); - } - - /// Seeks for a given time at the currently playing track - @override - void seek(Snowflake guildId, Duration time) { - sendPayload("seek", guildId, {"position": time.inMilliseconds}); - } - - /// Sets the volume for a guild player, [volume] should be a number between 1 to 1000 - @override - void volume(Snowflake guildId, int volume) { - final trimmed = volume.clamp(0, 1000); - - sendPayload("volume", guildId, {"volume": trimmed}); - } - - /// Pauses a guild player - @override - void pause(Snowflake guildId) { - setPause(guildId, true); - } - - /// Resumes the track playback of a guild player - @override - void resume(Snowflake guildId) { - setPause(guildId, false); - } - - /// Searches a given query over the lavalink api and returns the results - @override - Future searchTracks(String query) async { - final response = await _httpClient.get(Uri.parse("$_httpUri/loadtracks?identifier=$query"), headers: _defaultHeaders); - - if (!(response.statusCode == 200)) { - throw HttpException(response.statusCode); - } - - return Tracks(jsonDecode(utf8.decode(response.bodyBytes)) as Map); - } - - /// Searches a provided query on selected platform (YouTube by default), - /// if the query is a link it's searched directly by the link - @override - Future autoSearch( - String query, { - SearchPlatform platform = SearchPlatform.youtube, - }) async { - if (_urlRegex.hasMatch(query)) { - return searchTracks(query); - } - - return searchTracks("${platform.value}:$query"); - } - - /// Get the [PlayParameters] object for a specific track - @override - IPlayParameters play(Snowflake guildId, ITrack track, - {bool replace = false, Duration startTime = const Duration(), Duration? endTime, Snowflake? requester, Snowflake? channelId}) => - PlayParameters(this, track, guildId, replace, startTime, endTime, requester, channelId); - - /// Shuts down the node - @override - void shutdown() { - _nodeSendPort.send({"cmd": "SHUTDOWN"}); - } - - /// Create a new player for a specific guild - @override - IGuildPlayer createPlayer(Snowflake guildId) { - final player = GuildPlayer(this, guildId); - - _players[guildId] = player; - (_cluster as Cluster).nodeLocations[guildId] = options.nodeId; - - return player; - } - - /// Updates the [NodeOptions] property of the node, also reconnects the - /// websocket to the new options - @override - void updateOptions(NodeOptions newOptions) { - // Set the node id and client id before sending it to the isolate - newOptions.clientId = options.clientId; - newOptions.nodeId = options.nodeId; - - _nodeSendPort.send({"cmd": "UPDATE", "data": newOptions.toJson()}); - - options = newOptions; - } - - /// Tells the node to disconnect from lavalink server - @override - void disconnect() { - _nodeSendPort.send({"cmd": "DISCONNECT"}); - } - - /// Tells the node to reconnect to lavalink server - @override - void reconnect() { - _nodeSendPort.send({"cmd": "RECONNECT"}); - } -} diff --git a/lib/src/node/node_options.dart b/lib/src/node/node_options.dart deleted file mode 100644 index ef87fbd..0000000 --- a/lib/src/node/node_options.dart +++ /dev/null @@ -1,72 +0,0 @@ -import 'package:nyxx/nyxx.dart'; - -/// Class containing all node options needed to establish and mantain a connection -/// with lavalink server -class NodeOptions { - /// Host where lavalink is running - late final String host; - - /// Port used by lavalink rest & socket - late final int port; - - /// Whether to use a tls connection or not - late final bool ssl; - - /// Password to connect to the server - late final String password; - - /// Shards the bot is operating on - late final int shards; - - /// Max connect attempts before shutting down a node - late final int maxConnectAttempts; - - /// How much time should the node wait before trying to reconnect - /// to lavalink server again - late final Duration delayBetweenReconnections; - - /// Client id - late final Snowflake clientId; - - /// Node id, you **must** not set this yourself - late final int nodeId; - - late final String clientName; - - /// Constructor to build a new node builder - NodeOptions( - {this.host = "localhost", - this.port = 2333, - this.ssl = false, - this.password = "youshallnotpass", - this.shards = 1, - this.maxConnectAttempts = 5, - this.delayBetweenReconnections = const Duration(seconds: 5), - this.clientName = "nyxx_lavalink"}); - - NodeOptions.fromJson(Map json) { - host = json["host"] as String; - port = json["port"] as int; - ssl = json["ssl"] as bool; - password = json["password"] as String; - shards = json["shards"] as int; - clientId = Snowflake(json["clientId"] as int); - nodeId = json["nodeId"] as int; - maxConnectAttempts = json["maxConnectAttempts"] as int; - delayBetweenReconnections = Duration(milliseconds: json["delayBetweenReconnections"] as int); - clientName = json["clientName"] as String; - } - - Map toJson() => { - "host": host, - "port": port, - "ssl": ssl, - "password": password, - "shards": shards, - "clientId": clientId.id, - "nodeId": nodeId, - "maxConnectAttempts": maxConnectAttempts, - "delayBetweenReconnections": delayBetweenReconnections.inMilliseconds, - "clientName": clientName - }; -} diff --git a/lib/src/node/node_runner.dart b/lib/src/node/node_runner.dart deleted file mode 100644 index 03f05c3..0000000 --- a/lib/src/node/node_runner.dart +++ /dev/null @@ -1,155 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; - -import 'dart:isolate'; -import 'node_options.dart'; - -/* - The actual node runner - Following nyxx design, the node communicates with the cluster using json - First message will always be the [NodeOptions] data - - Can receive: - * SEND - Sends a given json payload directly to the server through the web socket - * CONNECT - Starts the connection to lavalink server - * UPDATE - Updates the current node data - * RECONNECT - Reconnects to lavalink server - * DISCONNECT - Disconnects from lavalink server - * SHUTDOWN - Shuts down the node and kills the isolate - - Can send: - * DISPATCH - Dispatch the given event - * DISCONNECTED - WebSocket disconnected - * CONNECTED - WebSocket connected - * ERROR - An error occurred - * EXITED - Node shutdown itself - * LOG - Log something -*/ -Future handleNode(SendPort clusterPort) async { - WebSocket? socket; - StreamSubscription? socketStream; - - // First thing to do is to return a send port to the cluster to communicate with the node - final receivePort = ReceivePort(); - final receiveStream = receivePort.asBroadcastStream(); - clusterPort.send(receivePort.sendPort); - - var node = NodeOptions.fromJson(await receiveStream.first as Map); - - void process(Map json) { - if (json["op"] == "event") { - clusterPort.send({"cmd": "DISPATCH", "nodeId": node.nodeId, "event": json["type"], "data": json}); - } else { - clusterPort.send({"cmd": "DISPATCH", "nodeId": node.nodeId, "event": json["op"], "data": json}); - } - } - - Future connect() async { - final address = node.ssl ? "wss://${node.host}:${node.port}" : "ws://${node.host}:${node.port}"; - var actualAttempt = 1; - - while (actualAttempt <= node.maxConnectAttempts) { - try { - clusterPort.send({ - "cmd": "LOG", - "nodeId": node.nodeId, - "level": "INFO", - "message": "[Node ${node.nodeId}] Trying to connect to lavalink ($actualAttempt/${node.maxConnectAttempts})" - }); - - await WebSocket.connect(address, headers: { - "Authorization": node.password, - "Num-Shards": node.shards, - "User-Id": node.clientId.id, - "Client-Name": node.clientName, - }).then((ws) { - clusterPort.send({"cmd": "CONNECTED", "nodeId": node.nodeId}); - - socket = ws; - - socketStream = socket!.listen( - (data) { - process(jsonDecode(data as String) as Map); - }, - onDone: () async { - clusterPort.send({"cmd": "DISCONNECTED", "nodeId": node.nodeId}); - await connect(); - - return; - }, - cancelOnError: true, - onError: (err) { - clusterPort.send({"cmd": "ERROR", "nodeId": node.nodeId, "code": socket!.closeCode, "reason": socket!.closeReason}); - }); - - return; - }); - - return; - // ignore: avoid_catches_without_on_clauses - } catch (e) { - clusterPort - .send({"cmd": "LOG", "nodeId": node.nodeId, "level": "WARNING", "message": "[Node ${node.nodeId}] Error while trying to connect to lavalink; $e"}); - } - - clusterPort.send({"cmd": "LOG", "nodeId": node.nodeId, "level": "WARNING", "message": "[Node ${node.nodeId}] Failed to connect to lavalink, retrying"}); - - actualAttempt += 1; - - await Future.delayed(node.delayBetweenReconnections); - } - - clusterPort.send({"cmd": "EXITED", "nodeId": node.nodeId}); - } - - Future disconnect() async { - await socket?.close(1000); - await socketStream?.cancel(); - - clusterPort.send({"cmd": "DISCONNECTED", "nodeId": node.nodeId}); - } - - Future reconnect() async { - await disconnect(); - await Future.delayed(const Duration(milliseconds: 300)); - await connect(); - } - - await for (final msg in receiveStream) { - switch (msg["cmd"]) { - case "SEND": - socket?.add(jsonEncode(msg["data"])); - break; - - case "CONNECT": - await connect(); - break; - - case "UPDATE": - node = NodeOptions.fromJson(msg["data"] as Map); - await reconnect(); - break; - - case "DISCONNECT": - await disconnect(); - break; - - case "RECONNECT": - await reconnect(); - break; - - case "SHUTDOWN": - { - clusterPort.send({"cmd": "EXITED", "nodeId": node.nodeId}); - await disconnect(); - receivePort.close(); - Isolate.current.kill(priority: Isolate.immediate); - } - break; - - default: - break; - } - } -} diff --git a/packages/lavalink/CHANGELOG.md b/packages/lavalink/CHANGELOG.md new file mode 100644 index 0000000..effe43c --- /dev/null +++ b/packages/lavalink/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/LICENSE b/packages/lavalink/LICENSE similarity index 100% rename from LICENSE rename to packages/lavalink/LICENSE diff --git a/packages/lavalink/README.md b/packages/lavalink/README.md new file mode 100644 index 0000000..8ffdc9b --- /dev/null +++ b/packages/lavalink/README.md @@ -0,0 +1,5 @@ +## lavalink + +A dart wrapper for the [Lavalink API](https://github.com/lavalink-devs/Lavalink). + +If you're looking to create a Discord bot that uses Lavalink, check out [nyxx](https://pub.dev/packages/nyxx) and [nyxx_lavalink](https://pub.dev/packages/nyxx_lavalink), which provide access to the Discord and Lavalink APIs in a unified way. diff --git a/packages/lavalink/analysis_options.yaml b/packages/lavalink/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/lavalink/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/lavalink/build.yaml b/packages/lavalink/build.yaml new file mode 100644 index 0000000..2024e49 --- /dev/null +++ b/packages/lavalink/build.yaml @@ -0,0 +1,7 @@ +targets: + $default: + builders: + json_serializable: + options: + field_rename: none + create_to_json: false diff --git a/packages/lavalink/example/example.dart b/packages/lavalink/example/example.dart new file mode 100644 index 0000000..75c717a --- /dev/null +++ b/packages/lavalink/example/example.dart @@ -0,0 +1,13 @@ +import 'package:lavalink/lavalink.dart'; + +void main() async { + final client = await LavalinkClient.connect( + Uri.http('localhost:2333'), + password: 'youshallnotpass', + userId: '1', + ); + + print(await client.getVersion()); + + await client.close(); +} diff --git a/packages/lavalink/lib/lavalink.dart b/packages/lavalink/lib/lavalink.dart new file mode 100644 index 0000000..3210185 --- /dev/null +++ b/packages/lavalink/lib/lavalink.dart @@ -0,0 +1,24 @@ +/// A wrapper for the Lavalink API. +library; + +export 'src/errors.dart'; +export 'src/connection.dart'; +export 'src/client.dart'; +export 'src/models/track.dart'; +export 'src/models/stats.dart'; +export 'src/models/route_planner.dart'; +export 'src/models/player.dart'; +export 'src/models/player_state.dart'; +export 'src/models/loaded_track_result.dart'; +export 'src/models/info.dart'; +export 'src/models/filters.dart'; +export 'src/messages/stats.dart'; +export 'src/messages/ready.dart'; +export 'src/messages/player_update.dart'; +export 'src/messages/message.dart'; +export 'src/messages/event.dart'; +export 'src/messages/events/websocket_closed.dart'; +export 'src/messages/events/track_stuck.dart'; +export 'src/messages/events/track_start.dart'; +export 'src/messages/events/track_exception.dart'; +export 'src/messages/events/track_end.dart'; diff --git a/packages/lavalink/lib/src/client.dart b/packages/lavalink/lib/src/client.dart new file mode 100644 index 0000000..c060526 --- /dev/null +++ b/packages/lavalink/lib/src/client.dart @@ -0,0 +1,272 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:lavalink/src/connection.dart'; +import 'package:lavalink/src/errors.dart'; +import 'package:lavalink/src/models/filters.dart'; +import 'package:lavalink/src/models/info.dart'; +import 'package:lavalink/src/models/loaded_track_result.dart'; +import 'package:lavalink/src/models/player.dart'; +import 'package:lavalink/src/models/route_planner.dart'; +import 'package:lavalink/src/models/stats.dart'; +import 'package:lavalink/src/models/track.dart'; + +/// A Lavalink client that does not create a session on the server and can only make certain HTTP +/// requests. +class HttpLavalinkClient { + /// The URI relative to which API routes will be resolved. + final Uri base; + + /// The password to use for authentication. + final String password; + + /// The name of this client. + final String clientName; + + /// The HTTP client used by this client. + final http.Client httpClient = http.Client(); + + HttpLavalinkClient({ + required this.base, + required this.password, + this.clientName = LavalinkClient.defaultClientName, + }); + + Future _executeSafe( + String method, + String endpoint, { + bool trace = false, + Object? body, + Map? queryParameters, + }) async { + // Avoid resetting the path of the base URI + if (endpoint.startsWith('/')) endpoint = endpoint.substring(1); + + final uri = base.resolveUri(Uri( + path: endpoint, + queryParameters: { + if (trace) 'trace': 'true', + ...?queryParameters, + }, + )); + + final request = http.Request(method, uri) + ..headers['Authorization'] = password + ..headers['Content-Type'] = 'application/json'; + if (body != null) request.bodyBytes = utf8.encode(jsonEncode(body)); + + final response = await http.Response.fromStream(await httpClient.send(request)); + final bodyText = utf8.decode(response.bodyBytes); + + if (response.statusCode >= 400) { + final parsedBody = jsonDecode(bodyText); + + throw LavalinkException( + timestamp: DateTime.fromMicrosecondsSinceEpoch(parsedBody['timestamp'] as int), + status: parsedBody['status'] as int, + error: parsedBody['error'] as String, + trace: parsedBody['trace'] as String?, + message: parsedBody['message'] as String, + path: parsedBody['path'] as String, + ); + } + + return bodyText; + } + + /// Load one or more tracks from an identifier. + Future loadTrack(String identifier) async { + final response = jsonDecode(await _executeSafe( + 'GET', + '/v4/loadtracks', + queryParameters: {'identifier': identifier}, + )); + return LoadResult.fromJson(response as Map); + } + + /// Decode a track from its encoded form. + Future decodeTrack(String encodedTrack) async { + final response = jsonDecode(await _executeSafe( + 'GET', + '/v4/decodetrack', + queryParameters: {'encodedTrack': encodedTrack}, + )); + return Track.fromJson(response as Map); + } + + /// Decode multiple tracks from their encoded form. + Future> decodeTracks(List encodedTracks) async { + final response = jsonDecode(await _executeSafe('POST', '/v4/decodetracks', body: encodedTracks)); + return (response as List).cast>().map(Track.fromJson).toList(); + } + + /// Get information about the server. + Future getInfo() async { + final response = jsonDecode(await _executeSafe('GET', '/v4/info')); + return LavalinkInfo.fromJson(response as Map); + } + + /// Get statistics from the server. + Future getStats() async { + final response = jsonDecode(await _executeSafe('GET', '/v4/stats')); + return LavalinkStats.fromJson(response as Map); + } + + /// Get the current version of the server. + Future getVersion() async => await _executeSafe('GET', '/version'); + + /// Get the current status of the RoutePlanner extension. + Future getRoutePlannerStatus() async { + final response = jsonDecode(await _executeSafe('GET', '/v4/routeplanner/status')); + return RoutePlannerStatus.fromJson(response as Map); + } + + /// Unmark a failed address in the RoutePlanner extension. + Future unmarkFailedAddress(String address) async { + await _executeSafe( + 'POST', + '/v4/routeplanner/free/address', + body: {'address': address}, + ); + } + + /// Unmark all failed addresses in the RoutePlanner extension. + Future unmarkAllFailedAddresses() async => await _executeSafe('POST', '/v4/routeplanner/free/all'); + + /// Close this client and all associated resources. + Future close() async { + httpClient.close(); + } +} + +/// A client that connects to a Lavalink server, providing methods to control the server and +/// exposing events received from the server. +class LavalinkClient extends HttpLavalinkClient { + /// The current version of `package:lavalink`. + static const version = '1.0.0'; + + /// The default client name used by this package. + static const defaultClientName = 'Dart-Lavalink/$version'; + + /// The user ID of this client. + final String userId; + + /// The websocket connection to the lavalink server, over which events are received. + LavalinkConnection get connection => _connection; + late final LavalinkConnection _connection; + + LavalinkClient._({ + required super.base, + required super.password, + required this.userId, + required super.clientName, + }); + + /// Create a new client connected to a Lavalink server. + static Future connect( + Uri base, { + required String password, + required String userId, + String clientName = defaultClientName, + }) async { + // Ensure the path will be used as a directory in resolve() + if (!base.path.endsWith('/')) base = base.replace(path: '${base.path}/'); + + final client = LavalinkClient._( + base: base, + password: password, + userId: userId, + clientName: clientName, + ); + + client._connection = await LavalinkConnection.connect(client); + + return client; + } + + /// List all players in the current session. + Future> listPlayers() async { + final response = jsonDecode(await _executeSafe('GET', '/v4/sessions/${connection.sessionId}/players')); + return (response as List).cast>().map(Player.fromJson).toList(); + } + + /// Get the player for a given guild. + Future getPlayer(String guildId) async { + final response = jsonDecode(await _executeSafe( + 'GET', + '/v4/sessions/${connection.sessionId}/players/$guildId', + )); + return Player.fromJson(response as Map); + } + + /// Create or update a player in a guild. + Future updatePlayer( + String guildId, { + bool? noReplace, + String? encodedTrack = _sentinelString, + String? identifier, + Duration? position, + Duration? endTime = _sentinelDuration, + int? volume, + bool? isPaused, + Filters? filters, + VoiceState? voice, + }) async { + final response = jsonDecode(await _executeSafe( + 'PATCH', + '/v4/sessions/${connection.sessionId}/players/$guildId', + body: { + if (!identical(encodedTrack, _sentinelString)) 'encodedTrack': encodedTrack, + if (identifier != null) 'identifier': identifier, + if (position != null) 'position': position.inMilliseconds, + if (!identical(endTime, _sentinelDuration)) 'endTime': endTime?.inMilliseconds, + if (volume != null) 'volume': volume, + if (isPaused != null) 'paused': isPaused, + if (filters != null) 'filters': filters.toJson(), + if (voice != null) 'voice': voice.toJson(), + }, + )); + return Player.fromJson(response as Map); + } + + /// Delete the player for a guild. + Future deletePlayer(String guildId) async { + await _executeSafe('DELETE', '/v4/sessions/${connection.sessionId}/players/$guildId'); + } + + /// Update the current session's properties. + Future<({bool resuming, Duration timeout})> updateSession({ + bool? resuming, + Duration? timeout, + }) async { + final response = jsonDecode(await _executeSafe( + 'PATCH', + '/v4/sessions/${connection.sessionId}', + body: { + if (resuming != null) 'resuming': resuming, + if (timeout != null) 'timeout': timeout.inSeconds, + }, + )) as Map; + + return ( + resuming: response['resuming'] as bool, + timeout: Duration(seconds: response['timeout'] as int), + ); + } + + @override + Future close() async { + await super.close(); + await connection.close(); + } +} + +const _sentinelString = '\u{1B}nyxx_lavalink'; +const _sentinelDuration = _SentinelDuration(); + +class _SentinelDuration implements Duration { + const _SentinelDuration(); + + @override + void noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} diff --git a/packages/lavalink/lib/src/connection.dart b/packages/lavalink/lib/src/connection.dart new file mode 100644 index 0000000..bc9879b --- /dev/null +++ b/packages/lavalink/lib/src/connection.dart @@ -0,0 +1,120 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/events/track_end.dart'; +import 'package:lavalink/src/messages/events/track_exception.dart'; +import 'package:lavalink/src/messages/events/track_start.dart'; +import 'package:lavalink/src/messages/events/track_stuck.dart'; +import 'package:lavalink/src/messages/events/websocket_closed.dart'; +import 'package:lavalink/src/messages/message.dart'; +import 'package:lavalink/src/messages/player_update.dart'; +import 'package:lavalink/src/messages/ready.dart'; +import 'package:lavalink/src/messages/stats.dart'; + +/// A websocket connection to a Lavalink server. +/// +/// Provides a stream interface that exposes messages sent by the server and errors encountered by +/// the connection. +class LavalinkConnection extends Stream { + /// The client this connection is for. + final LavalinkClient client; + + /// The ID of the current session this connection is using. + // Only safe to read after the first ready event, but instances of this class are not returned + // from LavalinkConnection.connect until that happens. + String get sessionId => _sessionId!; + // Use internally; will only be null before the first ready event. + String? _sessionId; + + final StreamController _messagesController = StreamController(); + final Completer _readyCompleter = Completer(); + WebSocket? _webSocket; + bool _closing = false; + + LavalinkConnection._({required this.client}) { + _run(); + } + + /// Create a new connection to a Lavalink server and wait for it to be ready. + static Future connect(LavalinkClient client) async { + final connection = LavalinkConnection._(client: client); + await connection._readyCompleter.future; + return connection; + } + + @override + StreamSubscription listen( + void Function(LavalinkMessage event)? onData, { + Function? onError, + void Function()? onDone, + bool? cancelOnError, + }) { + return _messagesController.stream.listen( + onData, + onError: onError, + onDone: onDone, + cancelOnError: cancelOnError, + ); + } + + Future _run() async { + while (!_closing) { + try { + _webSocket = await WebSocket.connect( + client.base.resolve('v4/websocket').replace(scheme: client.base.isScheme('https') ? 'wss' : 'ws').toString(), + headers: { + 'Authorization': client.password, + 'User-Id': client.userId, + 'Client-Name': client.clientName, + if (_sessionId != null) 'Session-Id': _sessionId!, + }, + ); + + await for (final message in _webSocket!) { + assert(message is String); + final json = { + ...jsonDecode(message), + // All the fromJson constructors read the client from the map. + 'client': client, + }; + + final parsedMessage = switch (json['op']) { + 'ready' => LavalinkReadyMessage.fromJson(json), + 'playerUpdate' => PlayerUpdateMessage.fromJson(json), + 'stats' => StatsMessage.fromJson(json), + 'event' => switch (json['type']) { + 'TrackStartEvent' => TrackStartEvent.fromJson(json), + 'TrackEndEvent' => TrackEndEvent.fromJson(json), + 'TrackExceptionEvent' => TrackExceptionEvent.fromJson(json), + 'TrackStuckEvent' => TrackStuckEvent.fromJson(json), + 'WebSocketClosedEvent' => WebSocketClosedEvent.fromJson(json), + final unknownEvent => throw FormatException('Unknown event type: $unknownEvent'), + }, + final unknownMessage => throw FormatException('Unknown message type: $unknownMessage') + }; + + _messagesController.add(parsedMessage); + if (parsedMessage is LavalinkReadyMessage) { + _sessionId = parsedMessage.sessionId; + if (!_readyCompleter.isCompleted) { + _readyCompleter.complete(null); + } + } + } + } catch (error, stack) { + _messagesController.addError(error, stack); + } + } + + await _messagesController.close(); + } + + /// Close this connection and all associated resources. + Future close() async { + _closing = true; + await _webSocket?.close(1000); + await _messagesController.done; + } +} diff --git a/packages/lavalink/lib/src/errors.dart b/packages/lavalink/lib/src/errors.dart new file mode 100644 index 0000000..e690e28 --- /dev/null +++ b/packages/lavalink/lib/src/errors.dart @@ -0,0 +1,33 @@ +/// An exception thrown when a Lavalink API call returns an error. +class LavalinkException implements Exception { + /// The time at which the error occurred. + final DateTime timestamp; + + /// The HTTP status code of the response. + final int status; + + /// The name of the error. + final String error; + + /// The stack trace at which the error occurred in the Lavalink server. + final String? trace; + + /// A description of the error. + final String message; + + /// The endpoint in which the error occurred. + final String path; + + /// Create a new [LavalinkException]. + LavalinkException({ + required this.timestamp, + required this.status, + required this.error, + required this.trace, + required this.message, + required this.path, + }); + + @override + String toString() => 'LavalinkException: $error ($status): $message'; +} diff --git a/packages/lavalink/lib/src/messages/event.dart b/packages/lavalink/lib/src/messages/event.dart new file mode 100644 index 0000000..eba6929 --- /dev/null +++ b/packages/lavalink/lib/src/messages/event.dart @@ -0,0 +1,18 @@ +import 'package:lavalink/src/messages/message.dart'; + +/// An event sent by a Lavalink server. +abstract class LavalinkEvent extends LavalinkMessage { + /// The type of this event. + final String type; + + /// The ID of the guild this event occurred in. + final String guildId; + + /// Create a new [LavalinkEvent]. + LavalinkEvent({ + required super.client, + required super.opType, + required this.type, + required this.guildId, + }); +} diff --git a/packages/lavalink/lib/src/messages/events/track_end.dart b/packages/lavalink/lib/src/messages/events/track_end.dart new file mode 100644 index 0000000..1b31b9a --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_end.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/event.dart'; +import 'package:lavalink/src/messages/message.dart'; +import 'package:lavalink/src/models/track.dart'; + +part 'track_end.g.dart'; + +/// An event sent when the end of a [Track] is reached. +@JsonSerializable() +class TrackEndEvent extends LavalinkEvent { + /// The track that ended. + final Track track; + + /// The reason why the track ended. + final String reason; + + /// Create a new [TrackEndEvent]. + TrackEndEvent({ + required super.client, + required super.opType, + required super.type, + required super.guildId, + required this.track, + required this.reason, + }); + + factory TrackEndEvent.fromJson(Map json) => _$TrackEndEventFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/events/track_end.g.dart b/packages/lavalink/lib/src/messages/events/track_end.g.dart new file mode 100644 index 0000000..f580e7e --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_end.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'track_end.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TrackEndEvent _$TrackEndEventFromJson(Map json) => TrackEndEvent( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + type: json['type'] as String, + guildId: json['guildId'] as String, + track: Track.fromJson(json['track'] as Map), + reason: json['reason'] as String, + ); diff --git a/packages/lavalink/lib/src/messages/events/track_exception.dart b/packages/lavalink/lib/src/messages/events/track_exception.dart new file mode 100644 index 0000000..f52570a --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_exception.dart @@ -0,0 +1,47 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/event.dart'; +import 'package:lavalink/src/messages/message.dart'; +import 'package:lavalink/src/models/track.dart'; + +part 'track_exception.g.dart'; + +/// An event sent when an exception is encountered while playing a track. +@JsonSerializable() +class TrackExceptionEvent extends LavalinkEvent { + /// The track that was playing. + final Track track; + + /// Information about the exception. + final ExceptionInfo exception; + + /// Create a new [TrackExceptionEvent]. + TrackExceptionEvent({ + required super.client, + required super.opType, + required super.type, + required super.guildId, + required this.track, + required this.exception, + }); + + factory TrackExceptionEvent.fromJson(Map json) => _$TrackExceptionEventFromJson(json); +} + +/// Information about an exception encountered by Lavalink. +@JsonSerializable() +class ExceptionInfo { + /// A message describing the exception. + final String? message; + + /// The severity of the exception. + final String severity; + + /// The cause of the exception. + final String cause; + + /// Create a new [ExceptionInfo]. + ExceptionInfo({required this.message, required this.severity, required this.cause}); + + factory ExceptionInfo.fromJson(Map json) => _$ExceptionInfoFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/events/track_exception.g.dart b/packages/lavalink/lib/src/messages/events/track_exception.g.dart new file mode 100644 index 0000000..0a43770 --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_exception.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'track_exception.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TrackExceptionEvent _$TrackExceptionEventFromJson(Map json) => TrackExceptionEvent( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + type: json['type'] as String, + guildId: json['guildId'] as String, + track: Track.fromJson(json['track'] as Map), + exception: ExceptionInfo.fromJson(json['exception'] as Map), + ); + +ExceptionInfo _$ExceptionInfoFromJson(Map json) => ExceptionInfo( + message: json['message'] as String?, + severity: json['severity'] as String, + cause: json['cause'] as String, + ); diff --git a/packages/lavalink/lib/src/messages/events/track_start.dart b/packages/lavalink/lib/src/messages/events/track_start.dart new file mode 100644 index 0000000..019526c --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_start.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/event.dart'; +import 'package:lavalink/src/messages/message.dart'; +import 'package:lavalink/src/models/track.dart'; + +part 'track_start.g.dart'; + +/// An event sent when a track starts playing. +@JsonSerializable() +class TrackStartEvent extends LavalinkEvent { + /// The track that started playing. + final Track track; + + /// Create a new [TrackStartEvent]. + TrackStartEvent({ + required super.client, + required super.opType, + required super.type, + required super.guildId, + required this.track, + }); + + factory TrackStartEvent.fromJson(Map json) => _$TrackStartEventFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/events/track_start.g.dart b/packages/lavalink/lib/src/messages/events/track_start.g.dart new file mode 100644 index 0000000..f856fea --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_start.g.dart @@ -0,0 +1,15 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'track_start.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TrackStartEvent _$TrackStartEventFromJson(Map json) => TrackStartEvent( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + type: json['type'] as String, + guildId: json['guildId'] as String, + track: Track.fromJson(json['track'] as Map), + ); diff --git a/packages/lavalink/lib/src/messages/events/track_stuck.dart b/packages/lavalink/lib/src/messages/events/track_stuck.dart new file mode 100644 index 0000000..421ac86 --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_stuck.dart @@ -0,0 +1,29 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/event.dart'; +import 'package:lavalink/src/messages/message.dart'; +import 'package:lavalink/src/models/track.dart'; + +part 'track_stuck.g.dart'; + +/// An event sent when a [Track] gets stuck while playing. +@JsonSerializable() +class TrackStuckEvent extends LavalinkEvent { + /// The track that got stuck. + final Track track; + + /// The threshold that was exceeded. + final Duration threshold; + + /// Create a new [TrackStuckEvent]. + TrackStuckEvent({ + required super.client, + required super.opType, + required super.type, + required super.guildId, + required this.track, + required this.threshold, + }); + + factory TrackStuckEvent.fromJson(Map json) => _$TrackStuckEventFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/events/track_stuck.g.dart b/packages/lavalink/lib/src/messages/events/track_stuck.g.dart new file mode 100644 index 0000000..feb5d89 --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/track_stuck.g.dart @@ -0,0 +1,16 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'track_stuck.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TrackStuckEvent _$TrackStuckEventFromJson(Map json) => TrackStuckEvent( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + type: json['type'] as String, + guildId: json['guildId'] as String, + track: Track.fromJson(json['track'] as Map), + threshold: Duration(microseconds: json['threshold'] as int), + ); diff --git a/packages/lavalink/lib/src/messages/events/websocket_closed.dart b/packages/lavalink/lib/src/messages/events/websocket_closed.dart new file mode 100644 index 0000000..9e4d1bb --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/websocket_closed.dart @@ -0,0 +1,32 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/event.dart'; +import 'package:lavalink/src/messages/message.dart'; + +part 'websocket_closed.g.dart'; + +/// An event sent when the websocket connection to Discord's voice servers is lost. +@JsonSerializable() +class WebSocketClosedEvent extends LavalinkEvent { + /// The close code of the websocket connection. + final int code; + + /// The reason the connection was closed. + final String reason; + + /// Whether the connection was closed by the remote server (Discord). + final bool? wasByRemote; + + /// Create a new [WebSocketClosedEvent]. + WebSocketClosedEvent({ + required super.client, + required super.opType, + required super.type, + required super.guildId, + required this.code, + required this.reason, + required this.wasByRemote, + }); + + factory WebSocketClosedEvent.fromJson(Map json) => _$WebSocketClosedEventFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/events/websocket_closed.g.dart b/packages/lavalink/lib/src/messages/events/websocket_closed.g.dart new file mode 100644 index 0000000..7119508 --- /dev/null +++ b/packages/lavalink/lib/src/messages/events/websocket_closed.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'websocket_closed.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +WebSocketClosedEvent _$WebSocketClosedEventFromJson(Map json) => WebSocketClosedEvent( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + type: json['type'] as String, + guildId: json['guildId'] as String, + code: json['code'] as int, + reason: json['reason'] as String, + wasByRemote: json['wasByRemote'] as bool?, + ); diff --git a/packages/lavalink/lib/src/messages/message.dart b/packages/lavalink/lib/src/messages/message.dart new file mode 100644 index 0000000..014a921 --- /dev/null +++ b/packages/lavalink/lib/src/messages/message.dart @@ -0,0 +1,18 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; + +LavalinkClient identity(LavalinkClient t) => t; + +/// The base class for all messages sent on a [LavalinkConnection]. +abstract class LavalinkMessage { + /// The client this message was for. + @JsonKey(fromJson: identity) + final LavalinkClient client; + + /// The type of this message. + @JsonKey(name: 'op') + final String opType; + + /// Create a new [LavalinkMessage]. + LavalinkMessage({required this.client, required this.opType}); +} diff --git a/packages/lavalink/lib/src/messages/player_update.dart b/packages/lavalink/lib/src/messages/player_update.dart new file mode 100644 index 0000000..594e64d --- /dev/null +++ b/packages/lavalink/lib/src/messages/player_update.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/message.dart'; +import 'package:lavalink/src/models/player_state.dart'; + +part 'player_update.g.dart'; + +/// A message sent when a [Player] changes state. +@JsonSerializable() +class PlayerUpdateMessage extends LavalinkMessage { + /// The ID of the guild the player is in. + final String guildId; + + /// The state of the player. + final PlayerState state; + + /// Create a new [PlayerUpdateMessage]. + PlayerUpdateMessage({ + required super.client, + required super.opType, + required this.guildId, + required this.state, + }); + + factory PlayerUpdateMessage.fromJson(Map json) => _$PlayerUpdateMessageFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/player_update.g.dart b/packages/lavalink/lib/src/messages/player_update.g.dart new file mode 100644 index 0000000..ccabde2 --- /dev/null +++ b/packages/lavalink/lib/src/messages/player_update.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player_update.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayerUpdateMessage _$PlayerUpdateMessageFromJson(Map json) => PlayerUpdateMessage( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + guildId: json['guildId'] as String, + state: PlayerState.fromJson(json['state'] as Map), + ); diff --git a/packages/lavalink/lib/src/messages/ready.dart b/packages/lavalink/lib/src/messages/ready.dart new file mode 100644 index 0000000..7222266 --- /dev/null +++ b/packages/lavalink/lib/src/messages/ready.dart @@ -0,0 +1,26 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/message.dart'; + +part 'ready.g.dart'; + +/// A message sent when a Lavalink session is initialized. +@JsonSerializable() +class LavalinkReadyMessage extends LavalinkMessage { + /// Whether the session was resumed. + @JsonKey(name: 'resumed') + final bool wasResumed; + + /// The ID of the session. + final String sessionId; + + /// Create a new [LavalinkReadyMessage]. + LavalinkReadyMessage({ + required super.client, + required super.opType, + required this.wasResumed, + required this.sessionId, + }); + + factory LavalinkReadyMessage.fromJson(Map json) => _$LavalinkReadyMessageFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/ready.g.dart b/packages/lavalink/lib/src/messages/ready.g.dart new file mode 100644 index 0000000..f73ab84 --- /dev/null +++ b/packages/lavalink/lib/src/messages/ready.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'ready.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LavalinkReadyMessage _$LavalinkReadyMessageFromJson(Map json) => LavalinkReadyMessage( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + wasResumed: json['resumed'] as bool, + sessionId: json['sessionId'] as String, + ); diff --git a/packages/lavalink/lib/src/messages/stats.dart b/packages/lavalink/lib/src/messages/stats.dart new file mode 100644 index 0000000..9cd3d86 --- /dev/null +++ b/packages/lavalink/lib/src/messages/stats.dart @@ -0,0 +1,25 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/client.dart'; +import 'package:lavalink/src/messages/message.dart'; +import 'package:lavalink/src/models/stats.dart'; + +part 'stats.g.dart'; + +dynamic _readFromSelf(Map json, String key) => json; + +/// A message containing statistics about the server. +@JsonSerializable() +class StatsMessage extends LavalinkMessage { + /// The statistics. + @JsonKey(readValue: _readFromSelf) + final LavalinkStats stats; + + /// Create a new [StatsMessage]. + StatsMessage({ + required super.client, + required super.opType, + required this.stats, + }); + + factory StatsMessage.fromJson(Map json) => _$StatsMessageFromJson(json); +} diff --git a/packages/lavalink/lib/src/messages/stats.g.dart b/packages/lavalink/lib/src/messages/stats.g.dart new file mode 100644 index 0000000..7c2f9b8 --- /dev/null +++ b/packages/lavalink/lib/src/messages/stats.g.dart @@ -0,0 +1,13 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stats.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +StatsMessage _$StatsMessageFromJson(Map json) => StatsMessage( + client: identity(json['client'] as LavalinkClient), + opType: json['op'] as String, + stats: LavalinkStats.fromJson(_readFromSelf(json, 'stats') as Map), + ); diff --git a/packages/lavalink/lib/src/models/filters.dart b/packages/lavalink/lib/src/models/filters.dart new file mode 100644 index 0000000..5e99492 --- /dev/null +++ b/packages/lavalink/lib/src/models/filters.dart @@ -0,0 +1,217 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'filters.g.dart'; + +/// Information about the filters a [Player] is using. +@JsonSerializable(createToJson: true) +class Filters { + /// The volume modifier. + final double? volume; + + /// A list of equalizers applied. + final List? equalizers; + + /// The karaoke effect applied. + final Karaoke? karaoke; + + /// The timescale applied. + final Timescale? timescale; + + /// The tremolo applied. + final Tremolo? tremolo; + + /// The vibrato applied. + final Vibrato? vibrato; + + /// The rotation effect applied. + final Rotation? rotation; + + /// The distortion applied. + final Distortion? distortion; + + /// The channel mix effect applied. + final ChannelMix? channelMix; + + /// The low-pass filter applied. + final LowPass? lowPass; + + /// Filters provided by plugins. + final Map>? pluginFilters; + + /// Create a new [Filters]. + Filters({ + this.volume, + this.equalizers, + this.karaoke, + this.timescale, + this.tremolo, + this.vibrato, + this.rotation, + this.distortion, + this.channelMix, + this.lowPass, + this.pluginFilters, + }); + + factory Filters.fromJson(Map json) => _$FiltersFromJson(json); + + Map toJson() => _$FiltersToJson(this); +} + +/// Information about an equalizer. +@JsonSerializable() +class Equalizer { + /// The band this equalizer targets. + final int band; + + /// The applied gain. + final double gain; + + /// Create a new [Equalizer]. + Equalizer({required this.band, required this.gain}); + + factory Equalizer.fromJson(Map json) => _$EqualizerFromJson(json); +} + +/// Information about a karaoke effect +@JsonSerializable() +class Karaoke { + /// The level of the effect. + final double? level; + + /// The mono level of the effect. + final double? monoLevel; + + /// The filter band used. + final double? filterBand; + + /// The filter width used. + final double? filterWidth; + + /// Create a new [Karaoke]. + Karaoke({ + required this.level, + required this.monoLevel, + required this.filterBand, + required this.filterWidth, + }); + + factory Karaoke.fromJson(Map json) => _$KaraokeFromJson(json); +} + +/// Information about a timescale effect. +@JsonSerializable() +class Timescale { + /// The speed of the effect. + final double? speed; + + /// The pitch of the effect. + final double? pitch; + + /// The rate of the effect. + final double? rate; + + /// Create a new [Timescale]. + Timescale({required this.speed, required this.pitch, required this.rate}); + + factory Timescale.fromJson(Map json) => _$TimescaleFromJson(json); +} + +/// Information about a tremolo effect. +@JsonSerializable() +class Tremolo { + /// The frequency of the effect. + final double? frequency; + + /// The depth of the effect. + final double? depth; + + /// Create a new [Tremolo]. + Tremolo({required this.frequency, required this.depth}); + + factory Tremolo.fromJson(Map json) => _$TremoloFromJson(json); +} + +/// Information about a vibrato effect. +@JsonSerializable() +class Vibrato { + /// The frequency of the effect. + final double? frequency; + + /// The depth of the effect. + final double? depth; + + /// Create a new [Vibrato]. + Vibrato({required this.frequency, required this.depth}); + + factory Vibrato.fromJson(Map json) => _$VibratoFromJson(json); +} + +/// Information about a rotation effect. +@JsonSerializable() +class Rotation { + /// The frequency of the effect in hertz. + final double rotationHz; + + /// Create a new [Rotation]. + Rotation({required this.rotationHz}); + + factory Rotation.fromJson(Map json) => _$RotationFromJson(json); +} + +/// Information about a distortion effect. +@JsonSerializable() +class Distortion { + final double? sinOffset; + final double? sinScale; + final double? cosOffset; + final double? cosScale; + final double? tanOffset; + final double? tanScale; + final double? offset; + final double? scale; + + /// Create a new [Distortion]. + Distortion({ + required this.sinOffset, + required this.sinScale, + required this.cosOffset, + required this.cosScale, + required this.tanOffset, + required this.tanScale, + required this.offset, + required this.scale, + }); + + factory Distortion.fromJson(Map json) => _$DistortionFromJson(json); +} + +/// Information about a channel mix effect. +@JsonSerializable() +class ChannelMix { + final double? leftToLeft; + final double? leftToRight; + final double? rightToLeft; + final double? rightToRight; + + /// Create a new [ChannelMix]. + ChannelMix({ + required this.leftToLeft, + required this.leftToRight, + required this.rightToLeft, + required this.rightToRight, + }); + + factory ChannelMix.fromJson(Map json) => _$ChannelMixFromJson(json); +} + +/// Information about a low pass filter effect. +@JsonSerializable() +class LowPass { + final double? smoothing; + + /// Create a new [LowPass]. + LowPass({required this.smoothing}); + + factory LowPass.fromJson(Map json) => _$LowPassFromJson(json); +} diff --git a/packages/lavalink/lib/src/models/filters.g.dart b/packages/lavalink/lib/src/models/filters.g.dart new file mode 100644 index 0000000..9a1d4c8 --- /dev/null +++ b/packages/lavalink/lib/src/models/filters.g.dart @@ -0,0 +1,91 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'filters.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Filters _$FiltersFromJson(Map json) => Filters( + volume: (json['volume'] as num?)?.toDouble(), + equalizers: (json['equalizers'] as List?)?.map((e) => Equalizer.fromJson(e as Map)).toList(), + karaoke: json['karaoke'] == null ? null : Karaoke.fromJson(json['karaoke'] as Map), + timescale: json['timescale'] == null ? null : Timescale.fromJson(json['timescale'] as Map), + tremolo: json['tremolo'] == null ? null : Tremolo.fromJson(json['tremolo'] as Map), + vibrato: json['vibrato'] == null ? null : Vibrato.fromJson(json['vibrato'] as Map), + rotation: json['rotation'] == null ? null : Rotation.fromJson(json['rotation'] as Map), + distortion: json['distortion'] == null ? null : Distortion.fromJson(json['distortion'] as Map), + channelMix: json['channelMix'] == null ? null : ChannelMix.fromJson(json['channelMix'] as Map), + lowPass: json['lowPass'] == null ? null : LowPass.fromJson(json['lowPass'] as Map), + pluginFilters: (json['pluginFilters'] as Map?)?.map( + (k, e) => MapEntry(k, e as Map), + ), + ); + +Map _$FiltersToJson(Filters instance) => { + 'volume': instance.volume, + 'equalizers': instance.equalizers, + 'karaoke': instance.karaoke, + 'timescale': instance.timescale, + 'tremolo': instance.tremolo, + 'vibrato': instance.vibrato, + 'rotation': instance.rotation, + 'distortion': instance.distortion, + 'channelMix': instance.channelMix, + 'lowPass': instance.lowPass, + 'pluginFilters': instance.pluginFilters, + }; + +Equalizer _$EqualizerFromJson(Map json) => Equalizer( + band: json['band'] as int, + gain: (json['gain'] as num).toDouble(), + ); + +Karaoke _$KaraokeFromJson(Map json) => Karaoke( + level: (json['level'] as num?)?.toDouble(), + monoLevel: (json['monoLevel'] as num?)?.toDouble(), + filterBand: (json['filterBand'] as num?)?.toDouble(), + filterWidth: (json['filterWidth'] as num?)?.toDouble(), + ); + +Timescale _$TimescaleFromJson(Map json) => Timescale( + speed: (json['speed'] as num?)?.toDouble(), + pitch: (json['pitch'] as num?)?.toDouble(), + rate: (json['rate'] as num?)?.toDouble(), + ); + +Tremolo _$TremoloFromJson(Map json) => Tremolo( + frequency: (json['frequency'] as num?)?.toDouble(), + depth: (json['depth'] as num?)?.toDouble(), + ); + +Vibrato _$VibratoFromJson(Map json) => Vibrato( + frequency: (json['frequency'] as num?)?.toDouble(), + depth: (json['depth'] as num?)?.toDouble(), + ); + +Rotation _$RotationFromJson(Map json) => Rotation( + rotationHz: (json['rotationHz'] as num).toDouble(), + ); + +Distortion _$DistortionFromJson(Map json) => Distortion( + sinOffset: (json['sinOffset'] as num?)?.toDouble(), + sinScale: (json['sinScale'] as num?)?.toDouble(), + cosOffset: (json['cosOffset'] as num?)?.toDouble(), + cosScale: (json['cosScale'] as num?)?.toDouble(), + tanOffset: (json['tanOffset'] as num?)?.toDouble(), + tanScale: (json['tanScale'] as num?)?.toDouble(), + offset: (json['offset'] as num?)?.toDouble(), + scale: (json['scale'] as num?)?.toDouble(), + ); + +ChannelMix _$ChannelMixFromJson(Map json) => ChannelMix( + leftToLeft: (json['leftToLeft'] as num?)?.toDouble(), + leftToRight: (json['leftToRight'] as num?)?.toDouble(), + rightToLeft: (json['rightToLeft'] as num?)?.toDouble(), + rightToRight: (json['rightToRight'] as num?)?.toDouble(), + ); + +LowPass _$LowPassFromJson(Map json) => LowPass( + smoothing: (json['smoothing'] as num?)?.toDouble(), + ); diff --git a/packages/lavalink/lib/src/models/info.dart b/packages/lavalink/lib/src/models/info.dart new file mode 100644 index 0000000..c11f6b8 --- /dev/null +++ b/packages/lavalink/lib/src/models/info.dart @@ -0,0 +1,111 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'info.g.dart'; + +DateTime _dateTimeFromMilliseconds(int ms) => DateTime.fromMillisecondsSinceEpoch(ms); + +/// Information about a Lavalink server. +@JsonSerializable() +class LavalinkInfo { + /// The version of ths server. + final Version version; + + /// The time at which the server was built. + @JsonKey(fromJson: _dateTimeFromMilliseconds) + final DateTime buildTime; + + /// Information about the git revision the server is running. + final Git git; + + /// The version of the JVM used to run the server. + final String jvm; + + /// The version of lavaplayer being used. + final String lavaplayer; + + /// A list of available source managers. + final List sourceManagers; + + /// A list of available filters. + final List filters; + + /// A list of plugins the server is using. + final List plugins; + + /// Create a new [LavalinkInfo]. + LavalinkInfo({ + required this.version, + required this.buildTime, + required this.git, + required this.jvm, + required this.lavaplayer, + required this.sourceManagers, + required this.filters, + required this.plugins, + }); + + factory LavalinkInfo.fromJson(Map json) => _$LavalinkInfoFromJson(json); +} + +/// Information about a Lavalink version. +@JsonSerializable() +class Version { + /// The version expressed as a semantic versioning string. + final String semver; + + /// The major version. + final int major; + + /// The minor version. + final int minor; + + /// The patch version. + final int patch; + + /// The pre-release information. + final String? preRelease; + + /// The build information. + final String? build; + + /// Create a new [Version]. + Version({ + required this.semver, + required this.major, + required this.minor, + required this.patch, + required this.preRelease, + required this.build, + }); + + factory Version.fromJson(Map json) => _$VersionFromJson(json); +} + +/// Information about a Git revision. +@JsonSerializable() +class Git { + /// The branch the revision is on. + final String branch; + + /// The commit hash. + final String commit; + + /// The time at which the commit was made. + @JsonKey(fromJson: _dateTimeFromMilliseconds) + final DateTime commitTime; + + /// Create a new [Git]. + Git({required this.branch, required this.commit, required this.commitTime}); + + factory Git.fromJson(Map json) => _$GitFromJson(json); +} + +@JsonSerializable() +class Plugin { + final String name; + final String version; + + Plugin({required this.name, required this.version}); + + factory Plugin.fromJson(Map json) => _$PluginFromJson(json); +} diff --git a/packages/lavalink/lib/src/models/info.g.dart b/packages/lavalink/lib/src/models/info.g.dart new file mode 100644 index 0000000..b3bbb44 --- /dev/null +++ b/packages/lavalink/lib/src/models/info.g.dart @@ -0,0 +1,38 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'info.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LavalinkInfo _$LavalinkInfoFromJson(Map json) => LavalinkInfo( + version: Version.fromJson(json['version'] as Map), + buildTime: _dateTimeFromMilliseconds(json['buildTime'] as int), + git: Git.fromJson(json['git'] as Map), + jvm: json['jvm'] as String, + lavaplayer: json['lavaplayer'] as String, + sourceManagers: (json['sourceManagers'] as List).map((e) => e as String).toList(), + filters: (json['filters'] as List).map((e) => e as String).toList(), + plugins: (json['plugins'] as List).map((e) => Plugin.fromJson(e as Map)).toList(), + ); + +Version _$VersionFromJson(Map json) => Version( + semver: json['semver'] as String, + major: json['major'] as int, + minor: json['minor'] as int, + patch: json['patch'] as int, + preRelease: json['preRelease'] as String?, + build: json['build'] as String?, + ); + +Git _$GitFromJson(Map json) => Git( + branch: json['branch'] as String, + commit: json['commit'] as String, + commitTime: _dateTimeFromMilliseconds(json['commitTime'] as int), + ); + +Plugin _$PluginFromJson(Map json) => Plugin( + name: json['name'] as String, + version: json['version'] as String, + ); diff --git a/packages/lavalink/lib/src/models/loaded_track_result.dart b/packages/lavalink/lib/src/models/loaded_track_result.dart new file mode 100644 index 0000000..8828e62 --- /dev/null +++ b/packages/lavalink/lib/src/models/loaded_track_result.dart @@ -0,0 +1,121 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/messages/events/track_exception.dart'; +import 'package:lavalink/src/models/track.dart'; + +part 'loaded_track_result.g.dart'; + +/// The result of loading a track, playlist or search. +sealed class LoadResult { + /// The type of result. + final String loadType; + + /// The data associated with the result. + dynamic get data; + + /// Create a new [LoadResult]. + LoadResult({required this.loadType}); + + factory LoadResult.fromJson(Map json) { + return switch (json['loadType']) { + 'track' => TrackLoadResult.fromJson(json), + 'playlist' => PlaylistLoadResult.fromJson(json), + 'search' => SearchLoadResult.fromJson(json), + 'empty' => EmptyLoadResult.fromJson(json), + 'error' => ErrorLoadResult.fromJson(json), + _ => throw FormatException('Unknown loadType', json['loadType']), + }; + } +} + +/// The [LoadResult] for a single track. +@JsonSerializable() +class TrackLoadResult extends LoadResult { + @override + final Track data; + + /// Create a new [TrackLoadResult]. + TrackLoadResult({required super.loadType, required this.data}); + + factory TrackLoadResult.fromJson(Map json) => _$TrackLoadResultFromJson(json); +} + +/// The [LoadResult] for a playlist. +@JsonSerializable() +class PlaylistLoadResult extends LoadResult { + @override + final Playlist data; + + /// Create a new [PlaylistLoadResult]. + PlaylistLoadResult({required super.loadType, required this.data}); + + factory PlaylistLoadResult.fromJson(Map json) => _$PlaylistLoadResultFromJson(json); +} + +/// A playlist of tracks. +@JsonSerializable() +class Playlist { + /// Information about this playlist. + final PlaylistInfo info; + + /// Additional info provided by plugins. + final Map pluginInfo; + + /// The tracks in this playlist. + final List tracks; + + /// Create a new [Playlist]. + Playlist({required this.info, required this.pluginInfo, required this.tracks}); + + factory Playlist.fromJson(Map json) => _$PlaylistFromJson(json); +} + +/// Information about a playlist. +@JsonSerializable() +class PlaylistInfo { + /// The name of the playlist. + final String name; + + /// The currently selected track. + final int selectedTrack; + + /// Create a ne w[PlaylistInfo]. + PlaylistInfo({required this.name, required this.selectedTrack}); + + factory PlaylistInfo.fromJson(Map json) => _$PlaylistInfoFromJson(json); +} + +/// The [LoadResult] for a search. +@JsonSerializable() +class SearchLoadResult extends LoadResult { + @override + final List data; + + /// Create a new [SearchLoadResult]. + SearchLoadResult({required super.loadType, required this.data}); + + factory SearchLoadResult.fromJson(Map json) => _$SearchLoadResultFromJson(json); +} + +/// An empty [LoadResult]. +@JsonSerializable() +class EmptyLoadResult extends LoadResult { + @override + void get data {} + + /// Create a new [EmptyLoadResult]. + EmptyLoadResult({required super.loadType}); + + factory EmptyLoadResult.fromJson(Map json) => _$EmptyLoadResultFromJson(json); +} + +/// The [LoadResult] returned when an error occurred while loading. +@JsonSerializable() +class ErrorLoadResult extends LoadResult { + @override + final ExceptionInfo data; + + /// Create a new [ErrorLoadResult]. + ErrorLoadResult({required super.loadType, required this.data}); + + factory ErrorLoadResult.fromJson(Map json) => _$ErrorLoadResultFromJson(json); +} diff --git a/packages/lavalink/lib/src/models/loaded_track_result.g.dart b/packages/lavalink/lib/src/models/loaded_track_result.g.dart new file mode 100644 index 0000000..3f93cc5 --- /dev/null +++ b/packages/lavalink/lib/src/models/loaded_track_result.g.dart @@ -0,0 +1,42 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'loaded_track_result.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +TrackLoadResult _$TrackLoadResultFromJson(Map json) => TrackLoadResult( + loadType: json['loadType'] as String, + data: Track.fromJson(json['data'] as Map), + ); + +PlaylistLoadResult _$PlaylistLoadResultFromJson(Map json) => PlaylistLoadResult( + loadType: json['loadType'] as String, + data: Playlist.fromJson(json['data'] as Map), + ); + +Playlist _$PlaylistFromJson(Map json) => Playlist( + info: PlaylistInfo.fromJson(json['info'] as Map), + pluginInfo: json['pluginInfo'] as Map, + tracks: (json['tracks'] as List).map((e) => Track.fromJson(e as Map)).toList(), + ); + +PlaylistInfo _$PlaylistInfoFromJson(Map json) => PlaylistInfo( + name: json['name'] as String, + selectedTrack: json['selectedTrack'] as int, + ); + +SearchLoadResult _$SearchLoadResultFromJson(Map json) => SearchLoadResult( + loadType: json['loadType'] as String, + data: (json['data'] as List).map((e) => Track.fromJson(e as Map)).toList(), + ); + +EmptyLoadResult _$EmptyLoadResultFromJson(Map json) => EmptyLoadResult( + loadType: json['loadType'] as String, + ); + +ErrorLoadResult _$ErrorLoadResultFromJson(Map json) => ErrorLoadResult( + loadType: json['loadType'] as String, + data: ExceptionInfo.fromJson(json['data'] as Map), + ); diff --git a/packages/lavalink/lib/src/models/player.dart b/packages/lavalink/lib/src/models/player.dart new file mode 100644 index 0000000..f0c5755 --- /dev/null +++ b/packages/lavalink/lib/src/models/player.dart @@ -0,0 +1,65 @@ +import 'package:json_annotation/json_annotation.dart'; +import 'package:lavalink/src/models/filters.dart'; +import 'package:lavalink/src/models/player_state.dart'; +import 'package:lavalink/src/models/track.dart'; + +part 'player.g.dart'; + +/// The state of the client playing audio in a guild. +@JsonSerializable() +class Player { + /// The ID of the guild. + final String guildId; + + /// The currently playing track. + final Track? track; + + /// The current volume. + final int volume; + + /// Whether this player is currently paused. + @JsonKey(name: 'paused') + final bool isPaused; + + /// The state of this player. + final PlayerState state; + + /// The state of this player's voice session. + final VoiceState voice; + + /// The filters used by this player. + final Filters filters; + + /// Create a new [Player]. + Player({ + required this.guildId, + required this.track, + required this.volume, + required this.isPaused, + required this.state, + required this.voice, + required this.filters, + }); + + factory Player.fromJson(Map json) => _$PlayerFromJson(json); +} + +/// The state of a [Player]'s voice session. +@JsonSerializable(createToJson: true) +class VoiceState { + /// The voice token being used. + final String token; + + /// The endpoint being used. + final String endpoint; + + /// The ID of the voice session. + final String sessionId; + + /// Create a new [VoiceState]. + VoiceState({required this.token, required this.endpoint, required this.sessionId}); + + factory VoiceState.fromJson(Map json) => _$VoiceStateFromJson(json); + + Map toJson() => _$VoiceStateToJson(this); +} diff --git a/packages/lavalink/lib/src/models/player.g.dart b/packages/lavalink/lib/src/models/player.g.dart new file mode 100644 index 0000000..b28f479 --- /dev/null +++ b/packages/lavalink/lib/src/models/player.g.dart @@ -0,0 +1,29 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Player _$PlayerFromJson(Map json) => Player( + guildId: json['guildId'] as String, + track: json['track'] == null ? null : Track.fromJson(json['track'] as Map), + volume: json['volume'] as int, + isPaused: json['paused'] as bool, + state: PlayerState.fromJson(json['state'] as Map), + voice: VoiceState.fromJson(json['voice'] as Map), + filters: Filters.fromJson(json['filters'] as Map), + ); + +VoiceState _$VoiceStateFromJson(Map json) => VoiceState( + token: json['token'] as String, + endpoint: json['endpoint'] as String, + sessionId: json['sessionId'] as String, + ); + +Map _$VoiceStateToJson(VoiceState instance) => { + 'token': instance.token, + 'endpoint': instance.endpoint, + 'sessionId': instance.sessionId, + }; diff --git a/packages/lavalink/lib/src/models/player_state.dart b/packages/lavalink/lib/src/models/player_state.dart new file mode 100644 index 0000000..150b465 --- /dev/null +++ b/packages/lavalink/lib/src/models/player_state.dart @@ -0,0 +1,36 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'player_state.g.dart'; + +DateTime _dateTimeFromMilliseconds(int ms) => DateTime.fromMillisecondsSinceEpoch(ms); +Duration _durationFromMilliseconds(int ms) => Duration(milliseconds: ms); + +/// The current state of a [Player]. +@JsonSerializable() +class PlayerState { + /// The current time for this player. + @JsonKey(fromJson: _dateTimeFromMilliseconds) + final DateTime time; + + /// The position of the current track. + @JsonKey(fromJson: _durationFromMilliseconds) + final Duration position; + + /// Whether the player is connected. + @JsonKey(name: 'connected') + final bool isConnected; + + /// The player's ping. + @JsonKey(fromJson: _durationFromMilliseconds) + final Duration ping; + + /// Create a new [PlayerState]. + PlayerState({ + required this.time, + required this.position, + required this.isConnected, + required this.ping, + }); + + factory PlayerState.fromJson(Map json) => _$PlayerStateFromJson(json); +} diff --git a/packages/lavalink/lib/src/models/player_state.g.dart b/packages/lavalink/lib/src/models/player_state.g.dart new file mode 100644 index 0000000..072658a --- /dev/null +++ b/packages/lavalink/lib/src/models/player_state.g.dart @@ -0,0 +1,14 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'player_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +PlayerState _$PlayerStateFromJson(Map json) => PlayerState( + time: _dateTimeFromMilliseconds(json['time'] as int), + position: _durationFromMilliseconds(json['position'] as int), + isConnected: json['connected'] as bool, + ping: _durationFromMilliseconds(json['ping'] as int), + ); diff --git a/packages/lavalink/lib/src/models/route_planner.dart b/packages/lavalink/lib/src/models/route_planner.dart new file mode 100644 index 0000000..c71b19b --- /dev/null +++ b/packages/lavalink/lib/src/models/route_planner.dart @@ -0,0 +1,97 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'route_planner.g.dart'; + +DateTime _dateTimeFromMilliseconds(int ms) => DateTime.fromMillisecondsSinceEpoch(ms); + +/// Status about the RoutePlanner extension on a Lavalink server. +@JsonSerializable() +class RoutePlannerStatus { + /// The current type of RoutePlanner. + final String? type; + + /// Information about the current RoutePlanner configuration. + final RoutePlannerDetails? details; + + /// Create a new [RoutePlannerStatus]. + RoutePlannerStatus({required this.type, required this.details}); + + factory RoutePlannerStatus.fromJson(Map json) => _$RoutePlannerStatusFromJson(json); +} + +/// Information about the configuration of the RoutePlanner extension on a Lavalink server. +@JsonSerializable() +class RoutePlannerDetails { + /// The IP block usable by the server. + final IpBlock ipBlock; + + /// The currently failing IP addresses. + final List failingAddresses; + + /// The number of rotations. + final String? rotateIndex; + + /// The current offset in the block. + final String? ipIndex; + + /// The current address being used. + final String? currentAddress; + + /// The current offset in the ip block. + final String? currentAddressIndex; + + /// The information in which /64 block ips are chosen. + /// + /// This number increases on each ban. + final String? blockIndex; + + /// Create a new [RoutePlannerDetails]. + RoutePlannerDetails({ + required this.ipBlock, + required this.failingAddresses, + required this.rotateIndex, + required this.ipIndex, + required this.currentAddress, + required this.currentAddressIndex, + required this.blockIndex, + }); + + factory RoutePlannerDetails.fromJson(Map json) => _$RoutePlannerDetailsFromJson(json); +} + +/// A block of IP addresses. +@JsonSerializable() +class IpBlock { + /// The type of this block. + final String type; + + /// THe size of this block. + final String size; + + /// Create a new [IpBlock]. + IpBlock({required this.type, required this.size}); + + factory IpBlock.fromJson(Map json) => _$IpBlockFromJson(json); +} + +/// Information about an IP address determined to be failing. +@JsonSerializable() +class FailingAddress { + /// The IP address. + final String failingAddress; + + /// The time at which the address failed. + @JsonKey(fromJson: _dateTimeFromMilliseconds) + final DateTime failingTimestamp; + + /// A human-readable string containing the time at which the address failed. + final String failingTime; + + FailingAddress({ + required this.failingAddress, + required this.failingTimestamp, + required this.failingTime, + }); + + factory FailingAddress.fromJson(Map json) => _$FailingAddressFromJson(json); +} diff --git a/packages/lavalink/lib/src/models/route_planner.g.dart b/packages/lavalink/lib/src/models/route_planner.g.dart new file mode 100644 index 0000000..11485ec --- /dev/null +++ b/packages/lavalink/lib/src/models/route_planner.g.dart @@ -0,0 +1,33 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'route_planner.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +RoutePlannerStatus _$RoutePlannerStatusFromJson(Map json) => RoutePlannerStatus( + type: json['type'] as String?, + details: json['details'] == null ? null : RoutePlannerDetails.fromJson(json['details'] as Map), + ); + +RoutePlannerDetails _$RoutePlannerDetailsFromJson(Map json) => RoutePlannerDetails( + ipBlock: IpBlock.fromJson(json['ipBlock'] as Map), + failingAddresses: (json['failingAddresses'] as List).map((e) => FailingAddress.fromJson(e as Map)).toList(), + rotateIndex: json['rotateIndex'] as String?, + ipIndex: json['ipIndex'] as String?, + currentAddress: json['currentAddress'] as String?, + currentAddressIndex: json['currentAddressIndex'] as String?, + blockIndex: json['blockIndex'] as String?, + ); + +IpBlock _$IpBlockFromJson(Map json) => IpBlock( + type: json['type'] as String, + size: json['size'] as String, + ); + +FailingAddress _$FailingAddressFromJson(Map json) => FailingAddress( + failingAddress: json['failingAddress'] as String, + failingTimestamp: _dateTimeFromMilliseconds(json['failingTimestamp'] as int), + failingTime: json['failingTime'] as String, + ); diff --git a/packages/lavalink/lib/src/models/stats.dart b/packages/lavalink/lib/src/models/stats.dart new file mode 100644 index 0000000..e878ac7 --- /dev/null +++ b/packages/lavalink/lib/src/models/stats.dart @@ -0,0 +1,92 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'stats.g.dart'; + +/// A collection of statistics about a Lavalink server. +@JsonSerializable() +class LavalinkStats { + /// The current number of players. + final int players; + + /// The current number of players playing audio. + final int playingPlayers; + + /// The server's uptime. + final Duration uptime; + + /// Statistics about the server's memory usage. + final MemoryStats memory; + + /// Statistics about the server's CPU usage. + final CpuStats cpu; + + /// Statistics about audio frames sent by the server. + final FrameStats? frames; + + /// Create a new [LavalinkStats]. + LavalinkStats({ + required this.players, + required this.playingPlayers, + required this.uptime, + required this.memory, + required this.cpu, + required this.frames, + }); + + factory LavalinkStats.fromJson(Map json) => _$LavalinkStatsFromJson(json); +} + +/// Statistics about a Lavalink server's memory usage. +@JsonSerializable() +class MemoryStats { + /// The free memory in bytes. + final int free; + + /// The used memory in bytes. + final int used; + + /// The allocated memory in bytes. + final int allocated; + + /// The reservable memory in bytes. + final int reservable; + + /// Create a new [MemoryStats]. + MemoryStats({ + required this.free, + required this.used, + required this.allocated, + required this.reservable, + }); + + factory MemoryStats.fromJson(Map json) => _$MemoryStatsFromJson(json); +} + +/// Statistics about a Lavalink server's CPU usage. +@JsonSerializable() +class CpuStats { + /// The number of cores on the system the server is running on. + final int cores; + + /// The current system load. + final double systemLoad; + + /// The current Lavalink load. + final double lavalinkLoad; + + /// Create a new [CpuStats]. + CpuStats({required this.cores, required this.systemLoad, required this.lavalinkLoad}); + + factory CpuStats.fromJson(Map json) => _$CpuStatsFromJson(json); +} + +@JsonSerializable() +class FrameStats { + final int sent; + final int nulled; + final int deficit; + + FrameStats({required this.sent, required this.nulled, required this.deficit}); + + factory FrameStats.fromJson(Map json) => _$FrameStatsFromJson(json); +} diff --git a/packages/lavalink/lib/src/models/stats.g.dart b/packages/lavalink/lib/src/models/stats.g.dart new file mode 100644 index 0000000..7c6038b --- /dev/null +++ b/packages/lavalink/lib/src/models/stats.g.dart @@ -0,0 +1,35 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'stats.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +LavalinkStats _$LavalinkStatsFromJson(Map json) => LavalinkStats( + players: json['players'] as int, + playingPlayers: json['playingPlayers'] as int, + uptime: Duration(microseconds: json['uptime'] as int), + memory: MemoryStats.fromJson(json['memory'] as Map), + cpu: CpuStats.fromJson(json['cpu'] as Map), + frames: json['frames'] == null ? null : FrameStats.fromJson(json['frames'] as Map), + ); + +MemoryStats _$MemoryStatsFromJson(Map json) => MemoryStats( + free: json['free'] as int, + used: json['used'] as int, + allocated: json['allocated'] as int, + reservable: json['reservable'] as int, + ); + +CpuStats _$CpuStatsFromJson(Map json) => CpuStats( + cores: json['cores'] as int, + systemLoad: (json['systemLoad'] as num).toDouble(), + lavalinkLoad: (json['lavalinkLoad'] as num).toDouble(), + ); + +FrameStats _$FrameStatsFromJson(Map json) => FrameStats( + sent: json['sent'] as int, + nulled: json['nulled'] as int, + deficit: json['deficit'] as int, + ); diff --git a/packages/lavalink/lib/src/models/track.dart b/packages/lavalink/lib/src/models/track.dart new file mode 100644 index 0000000..8ff12aa --- /dev/null +++ b/packages/lavalink/lib/src/models/track.dart @@ -0,0 +1,79 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'track.g.dart'; + +Duration _durationFromMilliseconds(int ms) => Duration(milliseconds: ms); + +/// An audio track. +@JsonSerializable() +class Track { + /// The encoded version of this track. + final String encoded; + + /// Information about this track. + final TrackInfo info; + + /// Extra information about this track provided by plugins. + final Map pluginInfo; + + /// Create a new [Track]. + Track({required this.encoded, required this.info, required this.pluginInfo}); + + factory Track.fromJson(Map json) => _$TrackFromJson(json); +} + +/// Information about a [Track]. +@JsonSerializable() +class TrackInfo { + /// The track's identifier. + final String identifier; + + /// Whether the track is seekable. + final bool isSeekable; + + /// The track's author. + final String author; + + /// The length of the track. + @JsonKey(fromJson: _durationFromMilliseconds) + final Duration length; + + /// Whether the track is a stream. + final bool isStream; + + /// The track's current playback position. + @JsonKey(fromJson: _durationFromMilliseconds) + final Duration position; + + /// The track's title. + final String title; + + /// The track's URI. + final Uri? uri; + + /// A URL to the track's artwork or cover image. + final Uri? artworkUrl; + + /// The track's ISRC. + final String? isrc; + + /// The name of the source providing the track. + final String sourceName; + + /// Create a new [TrackInfo]. + TrackInfo({ + required this.identifier, + required this.isSeekable, + required this.author, + required this.length, + required this.isStream, + required this.position, + required this.title, + required this.uri, + required this.artworkUrl, + required this.isrc, + required this.sourceName, + }); + + factory TrackInfo.fromJson(Map json) => _$TrackInfoFromJson(json); +} diff --git a/packages/lavalink/lib/src/models/track.g.dart b/packages/lavalink/lib/src/models/track.g.dart new file mode 100644 index 0000000..655a7eb --- /dev/null +++ b/packages/lavalink/lib/src/models/track.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'track.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +Track _$TrackFromJson(Map json) => Track( + encoded: json['encoded'] as String, + info: TrackInfo.fromJson(json['info'] as Map), + pluginInfo: json['pluginInfo'] as Map, + ); + +TrackInfo _$TrackInfoFromJson(Map json) => TrackInfo( + identifier: json['identifier'] as String, + isSeekable: json['isSeekable'] as bool, + author: json['author'] as String, + length: _durationFromMilliseconds(json['length'] as int), + isStream: json['isStream'] as bool, + position: _durationFromMilliseconds(json['position'] as int), + title: json['title'] as String, + uri: json['uri'] == null ? null : Uri.parse(json['uri'] as String), + artworkUrl: json['artworkUrl'] == null ? null : Uri.parse(json['artworkUrl'] as String), + isrc: json['isrc'] as String?, + sourceName: json['sourceName'] as String, + ); diff --git a/packages/lavalink/pubspec.yaml b/packages/lavalink/pubspec.yaml new file mode 100644 index 0000000..708e464 --- /dev/null +++ b/packages/lavalink/pubspec.yaml @@ -0,0 +1,17 @@ +name: lavalink +description: An implementation of the Lavalink API for Discord Voice. +version: 1.0.0 +repository: https://github.com/nyxx-discord/nyxx_lavalink + +environment: + sdk: ^3.0.0 + +dependencies: + http: ^1.1.0 + json_annotation: ^4.8.1 + +dev_dependencies: + build_runner: ^2.4.6 + json_serializable: ^6.7.1 + lints: ^3.0.0 + test: ^1.24.0 diff --git a/test/integration/.gitkeep b/packages/lavalink/test/unit/.gitkeep similarity index 100% rename from test/integration/.gitkeep rename to packages/lavalink/test/unit/.gitkeep diff --git a/CHANGELOG.md b/packages/nyxx_lavalink/CHANGELOG.md similarity index 96% rename from CHANGELOG.md rename to packages/nyxx_lavalink/CHANGELOG.md index 5fba2e1..6752471 100644 --- a/CHANGELOG.md +++ b/packages/nyxx_lavalink/CHANGELOG.md @@ -1,3 +1,9 @@ +## 4.0.0 +__09.12.2023__ + +- Update to nyxx 6.0.0 +- Update to Lavalink v4 + ## 3.2.0 __05.02_2023 diff --git a/packages/nyxx_lavalink/LICENSE b/packages/nyxx_lavalink/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/packages/nyxx_lavalink/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/nyxx_lavalink/README.md b/packages/nyxx_lavalink/README.md new file mode 100644 index 0000000..7c9fe44 --- /dev/null +++ b/packages/nyxx_lavalink/README.md @@ -0,0 +1,39 @@ +# nyxx_lavalink + +[![Discord](https://discordapp.com/api/guilds/846136758470443069/widget.png?style=shield)](https://discord.gg/nyxx) +[![pub](https://img.shields.io/pub/v/nyxx_lavalink.svg)](https://pub.dev/packages/nyxx_lavalink) +[![documentation](https://img.shields.io/badge/Documentation-nyxx__lavalink-yellow.svg)](https://pub.dev/documentation/nyxx_lavalink/latest/) + +A simple to use wrapper around the [Lavalink](https://lavalink.dev/) API for Discord bots using [nyxx](https://pub.dev/packages/nyxx). + +This package does not contain a Lavalink server. Follow the instructions [here](https://lavalink.dev/configuration/binary.html) (or [here](https://lavalink.dev/configuration/docker.html), if you're using Docker) to run a server, then provide the server address when creating your `LavalinkPlugin` instance. + +nyxx_lavalink uses Lavalink version 4. If you get errors, ensure your Lavalink server is running version 4 or above. + +## Other nyxx packages + +- [nyxx_commands](https://pub.dev/packages/nyxx_commands): A command framework for handling both simple & complex commands. +- [nyxx_extensions](https://pub.dev/packages/nyxx_extensions): Pagination, emoji utilities and other miscellaneous helpers for developing bots using nyxx. + +## Additional documentation & help + +The API documentation for the latest stable version can be found on [pub](https://pub.dev/documentation/nyxx_lavalink). + +### [Docs and wiki](https://nyxx.l7ssha.xyz) +Tutorials and wiki articles are hosted here, as well as API documentation for development versions from GitHub. + +### [Official nyxx Discord server](https://discord.gg/nyxx) +Our Discord server is where you can get help for any nyxx packages, as well as release announcements and discussions about the library. + +### [Discord API docs](https://discord.dev/) +Discord's API documentation details what nyxx implements & provides more detailed explanations of certain topics. + +### [Discord API Server](https://discord.gg/discord-api) +The unofficial guild for Discord Bot developers. To get help with nyxx check `#dart_nyxx` channel. + +### [Pub.dev docs](https://pub.dev/documentation/nyxx_lavalink) +The dartdocs page will always have the documentation for the latest release. + +## Contributing to Nyxx + +Read the [contributing document](https://github.com/nyxx-discord/nyxx_lavalink/blob/dev/CONTRIBUTING.md) diff --git a/packages/nyxx_lavalink/analysis_options.yaml b/packages/nyxx_lavalink/analysis_options.yaml new file mode 100644 index 0000000..dee8927 --- /dev/null +++ b/packages/nyxx_lavalink/analysis_options.yaml @@ -0,0 +1,30 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:lints/recommended.yaml + +# Uncomment the following section to specify additional rules. + +# linter: +# rules: +# - camel_case_types + +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/nyxx_lavalink/example/example.dart b/packages/nyxx_lavalink/example/example.dart new file mode 100644 index 0000000..73ac602 --- /dev/null +++ b/packages/nyxx_lavalink/example/example.dart @@ -0,0 +1,42 @@ +import 'dart:io'; + +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_lavalink/nyxx_lavalink.dart'; + +void main() async { + final lavalink = LavalinkPlugin( + base: Uri.http('localhost:2333'), + password: 'youshallnotpass', + ); + + final client = await Nyxx.connectGateway( + Platform.environment['TOKEN']!, + GatewayIntents.allUnprivileged, + options: GatewayClientOptions(plugins: [logging, cliIntegration, lavalink]), + ); + + client.onMessageCreate.listen((event) async { + // Mention the bot while in a voice channel to play Crab Rave + if (!event.mentions.contains(client.user)) return; + + final voiceState = event.guild?.voiceStates[event.message.author.id]; + if (voiceState == null || voiceState.channel == null) return; + + final voiceChannel = (await voiceState.channel!.fetch()) as VoiceChannel; + + final player = await voiceChannel.connectLavalink(); + + final searchResult = await lavalink.loadTrack('ytsearch:Crab Rave'); + + if (searchResult is! SearchLoadResult) throw Exception('Expected search load result'); + if (searchResult.data.isEmpty) throw Exception('No tracks found'); + + final track = searchResult.data.first; + + await player.play(track); + + await lavalink.onTrackEnd.firstWhere((e) => e.guildId == event.guild!.id.toString()); + + await player.disconnect(); + }); +} diff --git a/packages/nyxx_lavalink/lib/nyxx_lavalink.dart b/packages/nyxx_lavalink/lib/nyxx_lavalink.dart new file mode 100644 index 0000000..6d91e64 --- /dev/null +++ b/packages/nyxx_lavalink/lib/nyxx_lavalink.dart @@ -0,0 +1,8 @@ +/// Support for playing audio in Discord voice channels using nyxx, powered by Lavalink. +library; + +export 'src/plugin.dart' show LavalinkPlugin; +export 'src/lavalink_player.dart' show LavalinkPlayer; +export 'src/extensions.dart' show LavalinkVoiceChannel; + +export 'package:lavalink/lavalink.dart'; diff --git a/packages/nyxx_lavalink/lib/src/extensions.dart b/packages/nyxx_lavalink/lib/src/extensions.dart new file mode 100644 index 0000000..0a18d9e --- /dev/null +++ b/packages/nyxx_lavalink/lib/src/extensions.dart @@ -0,0 +1,45 @@ +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_lavalink/src/lavalink_player.dart'; +import 'package:nyxx_lavalink/src/plugin.dart'; + +final Expando _clientMapping = Expando('Lavalink plugin mapping'); +LavalinkPlugin _pluginFromClient(NyxxGateway client) { + if (_clientMapping[client] case final plugin?) { + return plugin; + } + + for (final plugin in client.options.plugins) { + if (plugin is! LavalinkPlugin) continue; + + _clientMapping[client] = plugin; + return plugin; + } + + throw StateError('No LavalinkPlugin found on client ${client.options.loggerName}'); +} + +NyxxGateway _ensureGateway(Nyxx client) { + if (client is NyxxGateway) { + return client; + } + + throw StateError('Lavalink was used on ${client.runtimeType} (only NyxxGateway is supported)'); +} + +/// Utilities for connecting to a voice channel using Lavalink. +extension LavalinkVoiceChannel on VoiceChannel { + /// Connect to this voice channel using Lavalink. + /// + /// The returned [LavalinkPlayer] can be used to control the player in the channel. + Future connectLavalink() async { + final client = _ensureGateway(manager.client); + final plugin = _pluginFromClient(client); + + final guildId = switch (this) { + GuildChannel(:final guildId) => guildId, + _ => throw UnsupportedError('Cannot connect to Lavalink outside of a guild'), + }; + + return await plugin.connect(client, id, guildId); + } +} diff --git a/packages/nyxx_lavalink/lib/src/lavalink_player.dart b/packages/nyxx_lavalink/lib/src/lavalink_player.dart new file mode 100644 index 0000000..4d0989e --- /dev/null +++ b/packages/nyxx_lavalink/lib/src/lavalink_player.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:lavalink/lavalink.dart'; +import 'package:nyxx/nyxx.dart'; +import 'package:nyxx_lavalink/src/plugin.dart'; + +/// An internal helper that forwards events from one stream to another, but can be closed. +class StreamForwarder { + final Stream sourceStream; + + late final StreamSubscription subscription; + final StreamController controller = StreamController.broadcast(); + + Stream get stream => controller.stream; + + StreamForwarder(this.sourceStream) { + subscription = sourceStream.listen( + controller.add, + onError: controller.addError, + onDone: controller.close, + ); + } + + Future close() async { + await subscription.cancel(); + await controller.close(); + } +} + +/// Provides methods to control and get information about a Lavalink [Player] in a voice channel. +class LavalinkPlayer { + /// The [NyxxGateway] client this player is attached to. + final NyxxGateway client; + + /// The [LavalinkClient] this player is attached to. + final LavalinkClient lavalinkClient; + + /// The [LavalinkPlugin] this player is attached to. + final LavalinkPlugin plugin; + + /// The ID of this player's guild. + final Snowflake guildId; + + /// A stream of [TrackEndEvent]s emitted by this player. + Stream get onTrackEnd => _onTrackEndController.stream; + late final StreamForwarder _onTrackEndController = StreamForwarder( + plugin.onTrackEnd.where((event) => event.guildId == guildId.toString() && event.client == lavalinkClient), + ); + + /// A stream of [TrackExceptionEvent]s emitted by this player. + Stream get onTrackException => _onTrackExceptionController.stream; + late final StreamForwarder _onTrackExceptionController = StreamForwarder( + plugin.onTrackException.where((event) => event.guildId == guildId.toString() && event.client == lavalinkClient), + ); + + /// A stream of [TrackStartEvent]s emitted by this player. + Stream get onTrackStart => _onTrackStartController.stream; + late final StreamForwarder _onTrackStartController = StreamForwarder( + plugin.onTrackStart.where((event) => event.guildId == guildId.toString() && event.client == lavalinkClient), + ); + + /// A stream of [TrackStuckEvent]s emitted by this player. + Stream get onTrackStuck => _onTrackStuckController.stream; + late final StreamForwarder _onTrackStuckController = StreamForwarder( + plugin.onTrackStuck.where((event) => event.guildId == guildId.toString() && event.client == lavalinkClient), + ); + + /// A stream of [WebSocketClosedEvent]s emitted by this player. + Stream get onWebsocketClosed => _onWebsocketClosedController.stream; + late final StreamForwarder _onWebsocketClosedController = StreamForwarder( + plugin.onWebsocketClosed.where((event) => event.guildId == guildId.toString() && event.client == lavalinkClient), + ); + + /// A stream of [PlayerUpdateMessage]s emitted by this player. + Stream get onUpdate => _onUpdateController.stream; + late final StreamForwarder _onUpdateController = StreamForwarder( + plugin.onPlayerUpdate.where((event) => event.guildId == guildId.toString() && event.client == lavalinkClient), + ); + + /// The currently playing track. + Track? get currentTrack => _currentTrack; + Track? _currentTrack; + + /// The current state of this player. + PlayerState get state => _state; + PlayerState _state = PlayerState( + time: DateTime.timestamp(), + position: Duration.zero, + isConnected: false, + ping: Duration.zero, + ); + + /// Create a new [LavalinkPlayer]. + LavalinkPlayer({ + required this.client, + required this.lavalinkClient, + required this.plugin, + required this.guildId, + }) { + onTrackEnd.listen((event) => _currentTrack = null); + onTrackStart.listen((event) => _currentTrack = event.track); + onUpdate.listen((event) => _state = event.state); + } + + /// Fetch the underlying Lavalink [Player] this [LavalinkPlayer] instance represents. + Future fetchPlayer() => lavalinkClient.getPlayer(guildId.toString()); + + /// Disconnect this player from the voice channel and destroy it. + Future disconnect() async { + await lavalinkClient.deletePlayer(guildId.toString()); + client.gateway.updateVoiceState( + guildId, + GatewayVoiceStateBuilder( + channelId: null, + isMuted: false, + isDeafened: false, + ), + ); + + await Future.wait([ + _onTrackEndController.close(), + _onTrackExceptionController.close(), + _onTrackStartController.close(), + _onTrackStuckController.close(), + _onWebsocketClosedController.close(), + _onUpdateController.close(), + ]); + } + + /// Play a track in the voice channel. + Future play(Track track) => lavalinkClient.updatePlayer(guildId.toString(), encodedTrack: track.encoded); + + /// Play a track in the voice channel using the track's encoded representation. + Future playEncoded(String encodedTrack) => lavalinkClient.updatePlayer(guildId.toString(), encodedTrack: encodedTrack); + + /// Play a track in the voice channel by the track's identifier. + Future playIdentifier(String identifier) => lavalinkClient.updatePlayer(guildId.toString(), identifier: identifier); + + /// Stop the currently playing track. + Future stopPlaying() => lavalinkClient.updatePlayer(guildId.toString(), encodedTrack: null); + + /// Seek to the provided [position] in the currently playing track. + Future seekTo(Duration position) => lavalinkClient.updatePlayer(guildId.toString(), position: position); + + /// Pause the currently playing track. + Future pause() => lavalinkClient.updatePlayer(guildId.toString(), isPaused: true); + + /// Resume playing the current track. + Future resume() => lavalinkClient.updatePlayer(guildId.toString(), isPaused: false); + + /// Set this player's volume. + Future setVolume(int volume) => lavalinkClient.updatePlayer(guildId.toString(), volume: volume); + + /// Set the filters this player uses. + Future setFilters(Filters filters) => lavalinkClient.updatePlayer(guildId.toString(), filters: filters); +} diff --git a/packages/nyxx_lavalink/lib/src/plugin.dart b/packages/nyxx_lavalink/lib/src/plugin.dart new file mode 100644 index 0000000..de9620f --- /dev/null +++ b/packages/nyxx_lavalink/lib/src/plugin.dart @@ -0,0 +1,239 @@ +import 'dart:async'; + +import 'package:lavalink/lavalink.dart' hide VoiceState; +import 'package:lavalink/lavalink.dart' as lavalink show VoiceState; +import 'package:nyxx/nyxx.dart'; + +import 'package:nyxx_lavalink/src/lavalink_player.dart'; + +/// An internal extension adding utility methods to [Stream]. +extension StreamExtension on Stream { + /// Equivalent to [Iterable.whereType]. + Stream whereType() => where((event) => event is U).cast(); +} + +/// A plugin that adds Lavalink support to [NyxxGateway] clients. +class LavalinkPlugin extends NyxxPlugin { + /// The current version of `nyxx_lavalink`. + static const version = '4.0.0'; + + /// The default client name used when connecting to lavalink. + static const clientName = 'nyxx_lavalink/$version'; + + /// The URI relative to which the Lavalink API is accessed. + /// + /// This URI must include the port on which the server is running. It must not include the `/v4` version suffix. + /// + /// Examples: + /// - If you are running Lavalink on your local machine: `Uri.http('localhost:2333')` (assuming the default port 2333 is used). + /// - If you are running Lavalink using docker and docker-compose: `Uri.http('lavalink:2333')` (assuming the Lavalink server is running in the service named + /// `lavalink`, that the default port 2333 is used and exposed, and that the `lavalink` service is linked to the current service). + final Uri base; + + /// The password to use when authenticating with the Lavalink server. + final String password; + + /// A stream of messages received by Lavalink clients created by this plugin. + Stream get onMessage => _messagesController.stream; + final StreamController _messagesController = StreamController.broadcast(); + + /// A stream of [StatsMessage]s received by Lavalink clients created by this plugin. + Stream get onStats => onMessage.whereType(); + + /// A stream of [LavalinkReadyMessage]s received by Lavalink clients created by this plugin. + Stream get onReady => onMessage.whereType(); + + /// A stream of [PlayerUpdateMessage]s received by Lavalink clients created by this plugin. + Stream get onPlayerUpdate => onMessage.whereType(); + + /// A stream of [LavalinkEvent]s received by Lavalink clients created by this plugin. + Stream get onEvent => onMessage.whereType(); + + /// A stream of [TrackEndEvent]s received by Lavalink clients created by this plugin. + Stream get onTrackEnd => onEvent.whereType(); + + /// A stream of [TrackExceptionEvent]s received by Lavalink clients created by this plugin. + Stream get onTrackException => onEvent.whereType(); + + /// A stream of [TrackStartEvent]s received by Lavalink clients created by this plugin. + Stream get onTrackStart => onEvent.whereType(); + + /// A stream of [TrackStuckEvent]s received by Lavalink clients created by this plugin. + Stream get onTrackStuck => onEvent.whereType(); + + /// A stream of [WebSocketClosedEvent]s received by Lavalink clients created by this plugin. + Stream get onWebsocketClosed => onEvent.whereType(); + + /// A stream of [LavalinkPlayer]s emitted when a Lavalink player connects to a voice channel. + Stream get onPlayerConnected => _playerConnectedController.stream; + final StreamController _playerConnectedController = StreamController.broadcast(); + + final LavalinkClient? _customClient; + + /// Create a new [LavalinkPlugin]. + LavalinkPlugin({ + required this.base, + required this.password, + }) : _customClient = null; + + /// Create a new [LavalinkPlugin] that uses a custom [LavalinkClient]. + /// + /// Using this constructor means every nyxx client attached to this plugin will use the provided Lavalink client. The Lavalink client will not be closed when + /// nyxx clients are closed. + LavalinkPlugin.usingClient(LavalinkClient this._customClient) + : base = _customClient.base, + password = _customClient.password; + + @override + NyxxPluginState createState() => _LavalinkPluginState(this); + + /// Connect to a voice channel using Lavalink. + /// + /// The returned [LavalinkPlayer] can be used to control the player in the channel. + Future connect(NyxxGateway client, Snowflake channelId, Snowflake guildId) async { + client.gateway.updateVoiceState( + guildId, + GatewayVoiceStateBuilder( + channelId: channelId, + isMuted: false, + isDeafened: false, + ), + ); + + return await onPlayerConnected.firstWhere((player) => player.guildId == guildId); + } + + Future _withClient(Future Function(HttpLavalinkClient client) f) async { + if (_customClient case final customClient?) { + return f(customClient); + } else { + final client = HttpLavalinkClient(base: base, password: password); + final result = await f(client); + await client.close(); + return result; + } + } + + /// Load information about the track or tracks identified by [identifier]. + Future loadTrack(String identifier) => _withClient((client) => client.loadTrack(identifier)); + + /// Decode a base64-encoded [Track]. + Future decodeTrack(String encodedTrack) => _withClient((client) => client.decodeTrack(encodedTrack)); + + /// Decode multiple base64-encoded [Track]s. + Future> decodeTracks(List encodedTracks) => _withClient((client) => client.decodeTracks(encodedTracks)); + + /// Fetch information about the Lavalink server. + Future fetchInfo() => _withClient((client) => client.getInfo()); + + /// Fetch statistics about the Lavalink server. + Future fetchStats() => _withClient((client) => client.getStats()); + + /// Fetch the current version of the Lavalink server. + Future fetchVersion() => _withClient((client) => client.getVersion()); +} + +class _LavalinkPluginState extends NyxxPluginState { + LavalinkClient? lavalinkClient; + + final Map _voiceStates = {}; + final Map _voiceServers = {}; + + _LavalinkPluginState(super.plugin); + + @override + Future afterConnect(NyxxGateway client) async { + await super.afterConnect(client); + + lavalinkClient = plugin._customClient ?? + await LavalinkClient.connect( + plugin.base, + password: plugin.password, + userId: client.user.id.toString(), + clientName: LavalinkPlugin.clientName, + ); + + lavalinkClient!.connection.listen( + plugin._messagesController.add, + onError: plugin._messagesController.addError, + cancelOnError: false, + ); + + client.onVoiceServerUpdate.listen((event) { + _voiceServers[event.guildId] = event; + _tryUpdateVoiceState(client, event.guildId); + }); + + client.onVoiceStateUpdate.listen((event) async { + if (event.state.userId != client.user.id) return; + + final guildId = event.state.guildId; + if (guildId == null) return; + + if (event.state.channelId == null) { + _voiceStates.remove(guildId); + _voiceServers.remove(guildId); + await lavalinkClient!.deletePlayer(guildId.toString()); + } else { + _voiceStates[guildId] = event.state; + _tryUpdateVoiceState(client, guildId); + } + }); + + client.onGuildCreate.listen((event) { + if (event is! GuildCreateEvent) return; + + for (final state in event.voiceStates) { + if (state.userId != client.user.id) continue; + + _voiceStates[event.guild.id] = state; + _tryUpdateVoiceState(client, event.guild.id); + break; + } + }); + + client.onGuildDelete.listen((event) { + if (event.isUnavailable) return; + + _voiceServers.remove(event.guild.id); + _voiceStates.remove(event.guild.id); + }); + } + + Future _tryUpdateVoiceState(NyxxGateway client, Snowflake guildId) async { + final voiceState = _voiceStates[guildId]; + final voiceServer = _voiceServers[guildId]; + + if (voiceState == null || voiceServer == null) return; + + if (voiceServer.endpoint == null) { + // TODO: Handle this case properly + return; + } + + await lavalinkClient!.updatePlayer( + guildId.toString(), + voice: lavalink.VoiceState( + endpoint: voiceServer.endpoint!, + sessionId: voiceState.sessionId, + token: voiceServer.token, + ), + ); + + plugin._playerConnectedController.add(LavalinkPlayer( + client: client, + lavalinkClient: lavalinkClient!, + plugin: plugin, + guildId: guildId, + )); + } + + @override + Future beforeClose(NyxxGateway client) async { + await super.beforeClose(client); + if (plugin._customClient == null) { + // We are using our own client. + await lavalinkClient!.close(); + } + } +} diff --git a/packages/nyxx_lavalink/pubspec.yaml b/packages/nyxx_lavalink/pubspec.yaml new file mode 100644 index 0000000..8a866cc --- /dev/null +++ b/packages/nyxx_lavalink/pubspec.yaml @@ -0,0 +1,17 @@ +name: nyxx_lavalink +description: Support for playing audio in Discord voice channels using nyxx, powered by Lavalink. +version: 4.0.0 + +repository: https://github.com/nyxx-discord/nyxx_lavalink +issue_tracker: https://github.com/nyxx-discord/nyxx_lavalink/issues + +environment: + sdk: ^3.0.0 + +dependencies: + nyxx: ^6.0.3 + lavalink: ^1.0.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/packages/nyxx_lavalink/test/unit/.gitkeep b/packages/nyxx_lavalink/test/unit/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/pubspec.yaml b/pubspec.yaml deleted file mode 100644 index 7253146..0000000 --- a/pubspec.yaml +++ /dev/null @@ -1,22 +0,0 @@ -name: nyxx_lavalink -version: 3.2.0 -description: Nyxx Lavalink Module -homepage: https://github.com/nyxx-discord/nyxx -repository: https://github.com/nyxx-discord/nyxx -documentation: https://nyxx.l7ssha.xyz -issue_tracker: https://github.com/nyxx-discord/nyxx/issues - -environment: - sdk: '>=2.13.0 <3.0.0' - -dependencies: - http: ^0.13.3 - logging: ^1.0.1 - nyxx: ^5.0.0 - -dev_dependencies: - build_runner: ^2.1.4 - coverage: ^1.0.3 - lints: ^1.0.1 - mockito: ^5.0.16 - test: ^1.19.0 diff --git a/test/mocks/node.mock.dart b/test/mocks/node.mock.dart deleted file mode 100644 index 7890828..0000000 --- a/test/mocks/node.mock.dart +++ /dev/null @@ -1,6 +0,0 @@ -import 'package:mockito/mockito.dart'; -import 'package:nyxx_lavalink/nyxx_lavalink.dart'; - -class NodeMock extends Fake implements INode { - -} diff --git a/test/mocks/nyxx_websocket.mock.dart b/test/mocks/nyxx_websocket.mock.dart deleted file mode 100644 index 8bcb973..0000000 --- a/test/mocks/nyxx_websocket.mock.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:mockito/mockito.dart'; -import 'package:nyxx/nyxx.dart'; - -class NyxxWebsocketMock extends Fake implements INyxxWebsocket { - @override - String get token => "test-token"; -} diff --git a/test/unit/stats_test.dart b/test/unit/stats_test.dart deleted file mode 100644 index f223493..0000000 --- a/test/unit/stats_test.dart +++ /dev/null @@ -1,43 +0,0 @@ -import 'package:test/expect.dart'; -import 'package:test/scaffolding.dart'; - -import 'package:nyxx_lavalink/src/model/stats.dart'; - -import '../mocks/node.mock.dart'; -import '../mocks/nyxx_websocket.mock.dart'; - -main() { - test("stats event deserialization", () { - final rawData = { - 'playingPlayers': 1, - 'players': 2, - 'uptime': 60, - 'memory': { - 'reservable': 1024, - 'used': 64, - 'free': 512, - 'allocated': 256 - }, - 'frameStats': { - 'sent': 1, - 'deficit': 1, - 'nulled': 1 - }, - 'cpu': { - 'cores': 2, - 'systemLoad': 0.54, - 'lavalinkLoad': 0.1 - }, - }; - - final resultEntity = StatsEvent(NyxxWebsocketMock(), NodeMock(), rawData); - - expect(resultEntity.players, equals(2)); - expect(resultEntity.players, equals(2)); - expect(resultEntity.uptime, equals(60)); - expect(resultEntity.memory.free, equals(512)); - expect(resultEntity.frameStats, isNotNull); - expect(resultEntity.frameStats!.sent, equals(1)); - expect(resultEntity.cpu.systemLoad, equals(0.54)); - }); -}