26 Commits
Author SHA1 Message Date
NotAlexNoyle e5f82f1005 Exclude CODEOWNERS from language statistics 2026-10-31 16:52:18 -04:00
NotAlexNoyle d0de6c5db9 Add CODEOWNERS (one staff approval) 2026-10-31 16:52:18 -04:00
NotAlexNoyle a0393d1bba Fix issues September 2026 2026-09-19 18:26:34 -04:00
root 3886ccbe65 chore: bump version to 0.6.7 and update dependencies 2026-08-23 03:03:22 +02:00
root fddde60e23 fix(youtube): resolve PO token extraction, client fallbacks and search routing 2026-08-23 03:03:16 +02:00
Christian F. Brinkmann 838ff69090 Mention the lavaplayer and lavalink updates in readme again. 2026-06-19 01:07:30 +02:00
Christian F. Brinkmann 66cc718d30 fix bad readme links 2026-06-19 01:01:51 +02:00
Christian F. Brinkmann ef134be9c0 Merge pull request #1 from chrisb09/playback-reporting
feat: implement playback reporting and statistics command (v0.6.6)
2026-05-17 09:44:17 +02:00
Christian F. Brinkmann (cloud) c25cda721a feat: implement playback reporting and statistics command (v0.6.6)
- Added DataLogService queries for server and user statistics.
- Implemented 'stats' command with support for time ranges and filters.
- Added StatsReportScheduler for periodic (daily/weekly/monthly) reports.
- Integrated play count display into the 'nowplaying' embed.
- Updated settings to support stats report channel and frequency configuration.
- Added documentation for analytics in setup.md and docker.md.
- Bumped version to 0.6.6 in pom.xml.
2026-05-17 09:41:14 +02:00
Christian F. Brinkmann (cloud) f95b50af89 Bump version to 0.6.5 and update YouTube common dependency to 1.18.1 2026-05-10 23:22:18 +02:00
Christian F. Brinkmann (cloud) 1e6904d329 Fix Chewtils version in about/debug output
Work around the broken VERSION_REVISION placeholder in jda-chewtils 2.2.1.

- render the about embed from LoggedAboutCmd
- load the Chewtils version from a filtered project resource
- use the same resolved version in DebugCmd
2026-04-08 16:22:18 +02:00
Christian F. Brinkmann (cloud) 5ff7b2f28e Update README and add setup, docker, and changelog documentation 2026-03-30 14:31:45 +02:00
Christian F. Brinkmann (cloud) 28dfdcf680 Add playback analytics logging and bump version to 0.6.3
Add JDBC-backed activity logging for queue, playback, search, skip, listener, and command events.

Logging is disabled by default and can be enabled with `dblog.enabled=true`. The default JDBC URL uses SQLite via `jdbc:sqlite:musicbot.db`, but other JDBC backends can be configured with the same settings block.
2026-03-30 14:11:25 +02:00
Christian F. Brinkmann (cloud) c5073b8755 Bump version to 0.6.2
Fix status message repost races and add a config toggle to disable the feature.

Add the statusmessages config option in reference.conf. When enabled, the handler now uses stronger synchronization and safer message-id reset behavior during reposts and edit failures.
2026-03-30 14:09:52 +02:00
Christian F. Brinkmann (cloud) 5501f02a97 Bump version to 0.6.1 2026-03-12 16:23:41 +01:00
Christian F. Brinkmann (cloud) 66e207eabe Add StatusMessageHandler to manage playback status messages and update Bot and Listener classes 2026-03-12 16:23:12 +01:00
Christian F. Brinkmann (cloud) db3a57d517 Update .gitignore to exclude all hidden files and ensure proper formatting 2026-03-12 15:28:38 +01:00
Christian F. Brinkmann 81fa3fed25 Bump version to 0.6.0 2026-03-06 10:15:59 +01:00
Christian F. Brinkmann 9484c373ff Downgrade jsoup dependency version from 1.15.3 to 1.14.2 2026-03-06 09:32:02 +01:00
Christian F. Brinkmann 860822d520 Update JLyrics dependency version from master-SNAPSHOT to master 2026-03-05 15:40:27 +01:00
Christian F. Brinkmann a0c313047f Add DAVE protocol support and update dependencies for JDA 6.3+ 2026-03-05 15:26:25 +01:00
Christian F. Brinkmann db69ea6787 Update Docker Hub link in README 2025-11-15 23:56:45 +01:00
Christian F. Brinkmann 5dba2c355b Bump version from 0.5.1 to 0.5.2 in pom.xml 2025-11-15 12:57:11 +01:00
Christian F. Brinkmann 9fd246ac25 Fix JDA-Chewtils dependency groupId and update version to jda-6.1.1-SNAPSHOT 2025-11-15 12:28:38 +01:00
Christian F. Brinkmann 6a15bbb0df Add MESSAGE_CONTENT and additional intents to JMusicBot 2025-11-15 12:28:23 +01:00
Christian F. Brinkmann 16bc176685 Bump version to 0.5.1 and enable additional intents for JDA 2025-11-15 10:56:43 +01:00
85 changed files with 5559 additions and 603 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
.gitea/CODEOWNERS linguist-detectable=false
+3
View File
@@ -0,0 +1,3 @@
# Pull requests request review from the eligible staff team(s).
# One approval from either an Admin or Developer satisfies branch protection.
* @trueog/Admins @trueog/Developers
-76
View File
@@ -1,76 +0,0 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at j@gro.sh. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq
-12
View File
@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: jagrosh
patreon: jagrosh
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
-64
View File
@@ -1,64 +0,0 @@
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug Report] Title Here "
labels: []
assignees: []
body:
- type: textarea
id: description
attributes:
label: Bug Description
description: Please provide a clear and concise description of what the bug is.
placeholder: The bug is...
validations:
required: true
- type: textarea
id: reproduce
attributes:
label: Steps to Reproduce
description: Provide steps you can take to reliably reproduce this bug
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Result
description: What did you expect to happen when this bug occurred?
placeholder: I expected...
validations:
required: true
- type: textarea
id: debug
attributes:
label: Debug Output
description: Please run the `debug` command and paste it here or upload it as an attachment. If you cannot run the command, please provide information about your system such as operating system version, JMusicBot version, etc.
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Additional Info
description: Any additional info that you think might be useful or relevant to this bug
- type: checkboxes
id: checks
attributes:
label: Checklist
description: Before submitting your issue, please affirm the following
options:
- label: I have looked for information about this [within the documentation](https://jmusicbot.com/)
required: true
- label: I have searched for similar issues [on the issues page](https://github.com/jagrosh/MusicBot/issues?q=is%3Aissue)
required: true
- label: I am using an official release from [the releases page](https://github.com/jagrosh/MusicBot/releases)
required: true
- type: input
id: version
attributes:
label: JMusicBot Version
description: Which version of JMusicBot are you running?
validations:
required: true
-8
View File
@@ -1,8 +0,0 @@
blank_issues_enabled: false
contact_links:
- name: Question
url: https://github.com/jagrosh/MusicBot/discussions/categories/questions
about: For general questions, please check the Discussions
- name: Troubleshooting
url: https://github.com/jagrosh/MusicBot/discussions/categories/troubleshooting
about: For troubleshooting help, please check the Discussions
@@ -1,52 +0,0 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature Request] Title Here"
labels: []
assignees: []
body:
- type: textarea
id: problem
attributes:
label: Is your feature request related to a problem? Please describe.
description: Please provide a clear and concise description of what the problem is.
placeholder: A common problem users face is...
validations:
required: true
- type: textarea
id: solution
attributes:
label: What is your ideal solution to the problem?
description: Describe a clear and concise description of what you want to happen.
placeholder: My solution to the above problem is...
validations:
required: true
- type: textarea
id: procedure
attributes:
label: How would this feature be used?
description: Describe the basic flow/steps of using this feature.
placeholder: |
1.
2.
3.
validations:
required: true
- type: textarea
id: additional-info
attributes:
label: Additional Info
description: Any additional info that you think might be useful or relevant to this feature request
- type: checkboxes
id: checks
attributes:
label: Checklist
description: Before submitting your issue, please affirm the following
options:
- label: I have checked [the documentation](https://jmusicbot.com/) to make sure this feature doesn't already exist
required: true
- label: I have searched for [similar feature requests](https://github.com/jagrosh/MusicBot/labels/enhancement)
required: true
- label: I have confirmed that my requests is **not** on the [list of things that will not be added](https://jmusicbot.com/things-that-wont-be-added)
required: true
- label: "I am running the latest version of the bot: ![Release](https://img.shields.io/github/release/jagrosh/MusicBot.svg)"
required: true
-14
View File
@@ -1,14 +0,0 @@
### This pull request...
- [ ] Fixes a bug
- [ ] Introduces a new feature
- [ ] Improves an existing feature
- [ ] Boosts code quality or performance
### Description
Replace this with a description of the pull request
### Purpose
Replace this with a description of the problem or use-case
### Relevant Issue(s)
This PR closes issue #...
-22
View File
@@ -1,22 +0,0 @@
name: Build and Test
on:
push:
branches:
- master
pull_request:
branches:
- master
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
cache: maven
- name: Build and Test with Maven
run: mvn --batch-mode --update-snapshots verify
-70
View File
@@ -1,70 +0,0 @@
name: Make Release
on:
workflow_dispatch:
inputs:
version_number:
description: 'Version Number'
required: true
type: string
info:
description: 'Description of this Release'
required: true
type: string
jobs:
build_jar:
name: Build Jar
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up JDK 11
uses: actions/setup-java@v2
with:
java-version: '11'
distribution: 'adopt'
cache: maven
- name: Set Version
uses: datamonsters/replace-action@v2
with:
files: 'pom.xml'
replacements: 'Snapshot=${{ github.event.inputs.version_number }}'
- name: Build with Maven
run: mvn --batch-mode --update-snapshots verify
- name: Rename jar
run: mv target/*-All.jar JMusicBot-${{ github.event.inputs.version_number }}.jar
- name: Upload jar
uses: actions/upload-artifact@v3
with:
name: jar
path: JMusicBot-${{ github.event.inputs.version_number }}.jar
if-no-files-found: error
create_release:
name: Create Release
runs-on: ubuntu-latest
needs: build_jar
steps:
- name: Download a Build Artifact
uses: actions/download-artifact@v3.0.0
with:
name: jar
path: .
- name: Show Artifacts
run: ls -R
- name: Create Release
uses: ncipollo/release-action@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
tag: ${{ github.event.inputs.version_number }}
name: Version ${{ github.event.inputs.version_number }}
draft: true
prerelease: false
artifacts: "*.jar"
body: |
${{ github.event.inputs.info }}
---
### Setup
https://jmusicbot.com/setup
https://jmusicbot.com/config
# Download: [JMusicBot-${{ github.event.inputs.version_number }}.jar](https://github.com/jagrosh/MusicBot/releases/download/${{ github.event.inputs.version_number }}/JMusicBot-${{ github.event.inputs.version_number }}.jar)
+2 -1
View File
@@ -5,4 +5,5 @@
*.json
*.txt
nb*.xml
dockerfile
dockerfile
.*!.mvn/
+3
View File
@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.9/apache-maven-3.9.9-bin.zip
+72 -116
View File
@@ -1,147 +1,103 @@
<img align="right" src="https://i.imgur.com/zrE80HY.png" height="200" width="200">
## Demo
# JMusicBot (TrueOG Network fork)
If you only want to temporarily join the demo server, use this link: [https://discord.gg/4cp9CvjwaW](https://discord.gg/4cp9CvjwaW)
A self-hosted Discord music bot, forked by the [TrueOG Network](https://true-og.net) from [chrisb09/MusicBot](https://github.com/chrisb09/MusicBot), which is in turn a modernised fork of [jagrosh/MusicBot](https://github.com/jagrosh/MusicBot).
If you want to join the demo server permanently, use this link: [https://discord.gg/hK95396uB3](https://discord.gg/hK95396uB3)
The upstream fork keeps the bot working on current Discord, JDA, and YouTube; this fork keeps it running on TrueOG's infrastructure.
# This fork
## TrueOG Network Fork Changes
We had to update to JDA 6, switch to jda-chewtils to make the bot work again.
Also, we somewhat regularly bump [lavaplayer](https://mvnrepository.com/artifact/dev.arbjerg/lavaplayer)/[lavalink](https://maven.lavalink.dev/#/releases/dev/lavalink/youtube/common) to their new versions. I will try to update this automatically in the future.
- Maven Wrapper (`./mvnw`) so builds use a pinned Maven version instead of whatever is installed
- Selenium bumped to 4.49.0 with the CDP v153 devtools module, so YouTube PO-token extraction works with current Chromium
- `maven-shade-plugin` now merges `META-INF/services`, so bundled `CdpInfo` (and other SPI) registrations are no longer silently dropped from the fat jar
- YouTube PO-token capture handles the gzip-compressed `/youtubei/v1/player` request body that YouTube web client 2.20260918+ sends, and logs a warning instead of silently timing out when the body cannot be parsed
Additionally, this fork includes the following PRs from other people:
## Inherited From Upstream (chrisb09)
### Support for logging into your (burner) youtube account:
- JDA 6 support and current dependency updates
- DAVE voice encryption support for current Discord voice requirements
- Optional YouTube account login, PO token, and visitor-data support
- Optional per-channel playback status message
- Optional JDBC/SQLite playback analytics logging
- Regular [lavaplayer](https://mvnrepository.com/artifact/dev.arbjerg/lavaplayer)/[lavalink](https://maven.lavalink.dev/#/releases/dev/lavalink/youtube/common) bumps
[PR-1670](https://github.com/jagrosh/MusicBot/pull/1670)
## Quick Links
### To allow building the project from source
[PR-1703](https://github.com/jagrosh/MusicBot/pull/1703)
- Setup guide: [docs/setup.md](/docs/setup.md)
- Upstream fork changelog: [docs/fork-changelog.md](/docs/fork-changelog.md)
### Add YouTube PO token & IP rotation
https://github.com/jagrosh/MusicBot/pull/1772
## Features
- Easy self-hosting
- Fast song loading
- Smooth playback
- DJ role and server-specific settings
- Queue management and playlist support
- Support for many sources, streams, and local files
### For PO_Tokens: In your bot folder, edit "config.txt" and add
```
ytpotoken = "PO_TOKEN_HERE"
ytvisitordata = "VISITOR_DATA_HERE"
## Supported Sources And Formats
JMusicBot supports all sources and formats supported by [lavaplayer](https://github.com/sedmelluq/lavaplayer#supported-formats).
Sources:
- YouTube
- SoundCloud
- Bandcamp
- Vimeo
- Twitch streams
- Local files
- HTTP URLs
Formats:
- MP3
- FLAC
- WAV
- Matroska/WebM
- MP4/M4A
- OGG streams
- AAC streams
- Stream playlists such as M3U and PLS
## Building
```bash
./mvnw clean package
```
To get po_token and visitor_data, use [youtube-trusted-session-generator](https://github.com/iv-org/youtube-trusted-session-generator)
The runnable jar is written to `target/JMusicBot-<version>-All.jar`.
## Setup Notes
If you have problems with this fork, you could also check out [this other fork](https://github.com/SeVile/MusicBot).
1. Create a Discord bot and get its token, see [here](https://jmusicbot.com/getting-a-bot-token/).
2. Enable the `MESSAGE_CONTENT` intent in the Discord Developer Portal [here](https://discord.com/developers/applications) ([Wiki link](https://jda.wiki/using-jda/gateway-intents-and-member-cache-policy/)).
3. Configure `config.txt`.
4. Run the jar: `java -Dnogui=true -jar target/JMusicBot-<version>-All.jar`
## Requirements for 0.5+ and above
For the detailed version, see [docs/setup.md](/docs/setup.md).
Since we updated JDA from version 4 to 6 to fix various issues, we had to enable the MESSAGE_CONTENT intent. If you let your bot join more than 100 servers(guilds), you now need to enable this intent in the [Discord Developer Portal](https://discord.com/developers/applications).
More information can be found in the [JDA documentation](https://jda.wiki/using-jda/gateway-intents-and-member-cache-policy/).
Note, however, that this is a privileged intent and will require a valid use-case for your bot to be verified in over 75 servers (it will only show up in the portal when on at least 76 servers according to the documentation).
## YouTube Playback
## Easy deployment via docker
YouTube requires a PO token, which the bot obtains at startup by driving a headless Chromium through Selenium. Chromium must be installed, and the bundled Selenium CDP module must match the installed Chromium major version (currently v153 — see `pom.xml`). If Chromium is upgraded past that, bump `selenium-java` and the `selenium-devtools-vNNN` exclusions in `pom.xml` and rebuild.
Additionally, i thought a corresponding docker image would make things better:
The image can be found on [dockerhub](https://hub.docker.com/repository/docker/chrisb09/jmusicbot/general)
Docker-compose:
```yaml
version: '2.2'
services:
jmusicbot:
image: chrisb09/jmusicbot:latest
container_name: jmusicbot
restart: unless-stopped
volumes:
- ./config:/jmb/config
```
although, obviously you should change `./config` to whatever path is right for your setup.
Source code for the docker image is also on [Github](https://github.com/chrisb09/jmb-container).
### Windows
If you want to run the bot on a windows host without docker, you obviously need to install java, and if you want to run it in the background something like [nssm](http://nssm.cc/) can be helpful.
## YouTube-Login
Obviously you still need to log in if you want to use youtube properly, hence you need to enable this in the config.
If you are using a pre-existing config, simply add:
```
/ If you set this to true, the bot will use a Google account to play YouTube tracks.
// This is only needed if you are experiencing issues with YouTube playback.
// Once enabled, instructions for login will be written to the console and DMs.
// DO NOT use your main Google account! Use an alternative account.
Google account login is also supported as a fallback:
```conf
youtubeoauth2=true
```
If you start with a clean slate this line should already be present, albeit with `false` being the default.
As the author of the PR reiterates multiple times, do not use your main google account as using your account for a bot is most certainly against the ToS and thus you are endangering said account.
Do not use your main Google account. Use a burner or otherwise disposable account instead. Details are in [docs/setup.md](/docs/setup.md).
To add the account you simply have to follow the very basic instructions printed either in the console or sent to you from the bot via discord after you have started it, open the linked google page and paste the code.
## DAVE / Voice Encryption
# JMusicBot
Discord requires DAVE for voice on modern API versions. The bot configures `libdave-jvm` during startup and bundles native binaries for common Linux, Windows, and macOS targets.
[![Downloads](https://img.shields.io/github/downloads/jagrosh/MusicBot/total.svg)](https://github.com/jagrosh/MusicBot/releases/latest)
[![Stars](https://img.shields.io/github/stars/jagrosh/MusicBot.svg)](https://github.com/jagrosh/MusicBot/stargazers)
[![Release](https://img.shields.io/github/release/jagrosh/MusicBot.svg)](https://github.com/jagrosh/MusicBot/releases/latest)
[![License](https://img.shields.io/github/license/jagrosh/MusicBot.svg)](https://github.com/jagrosh/MusicBot/blob/master/LICENSE)
[![Discord](https://discordapp.com/api/guilds/147698382092238848/widget.png)](https://discord.gg/0p9LSGoRLu6Pet0k)<br>
[![CircleCI](https://dl.circleci.com/status-badge/img/gh/jagrosh/MusicBot/tree/master.svg?style=svg)](https://dl.circleci.com/status-badge/redirect/gh/jagrosh/MusicBot/tree/master)
[![Build and Test](https://github.com/jagrosh/MusicBot/actions/workflows/build-and-test.yml/badge.svg)](https://github.com/jagrosh/MusicBot/actions/workflows/build-and-test.yml)
[![CodeFactor](https://www.codefactor.io/repository/github/jagrosh/musicbot/badge)](https://www.codefactor.io/repository/github/jagrosh/musicbot)
If you deploy on an unsupported architecture, add the matching native dependency in [pom.xml](/pom.xml).
A cross-platform Discord music bot with a clean interface, and that is easy to set up and run yourself!
## Upstream And Related Work
[![Setup](http://i.imgur.com/VvXYp5j.png)](https://jmusicbot.com/setup)
This fork incorporates ideas and patches related to:
## Features
* Easy to run (just make sure Java is installed, and run!)
* Fast loading of songs
* No external keys needed (besides a Discord Bot token)
* Smooth playback
* Server-specific setup for the "DJ" role that can moderate the music
* Clean and beautiful menus
* Supports many sites, including Youtube, Soundcloud, and more
* Supports many online radio/streams
* Supports local files
* Playlist support (both web/youtube, and local)
## Supported sources and formats
JMusicBot supports all sources and formats supported by [lavaplayer](https://github.com/sedmelluq/lavaplayer#supported-formats):
### Sources
* YouTube
* SoundCloud
* Bandcamp
* Vimeo
* Twitch streams
* Local files
* HTTP URLs
### Formats
* MP3
* FLAC
* WAV
* Matroska/WebM (AAC, Opus or Vorbis codecs)
* MP4/M4A (AAC codec)
* OGG streams (Opus, Vorbis and FLAC codecs)
* AAC streams
* Stream playlists (M3U and PLS)
## Example
![Loading Example...](https://i.imgur.com/kVtTKvS.gif)
## Setup
Please see the [Setup Page](https://jmusicbot.com/setup) to run this bot yourself!
## Questions/Suggestions/Bug Reports
**Please read the [Issues List](https://github.com/jagrosh/MusicBot/issues) before suggesting a feature**. If you have a question, need troubleshooting help, or want to brainstorm a new feature, please start a [Discussion](https://github.com/jagrosh/MusicBot/discussions). If you'd like to suggest a feature or report a reproducible bug, please open an [Issue](https://github.com/jagrosh/MusicBot/issues) on this repository. If you like this bot, be sure to add a star to the libraries that make this possible: [**JDA**](https://github.com/DV8FromTheWorld/JDA) and [**lavaplayer**](https://github.com/sedmelluq/lavaplayer)!
## Editing
This bot (and the source code here) might not be easy to edit for inexperienced programmers. The main purpose of having the source public is to show the capabilities of the libraries, to allow others to understand how the bot works, and to allow those knowledgeable about java, JDA, and Discord bot development to contribute. There are many requirements and dependencies required to edit and compile it, and there will not be support provided for people looking to make changes on their own. Instead, consider making a feature request (see the above section). If you choose to make edits, please do so in accordance with the Apache 2.0 License.
- [PR-1670](https://github.com/jagrosh/MusicBot/pull/1670)
- [PR-1703](https://github.com/jagrosh/MusicBot/pull/1703)
- [PR-1772](https://github.com/jagrosh/MusicBot/pull/1772)
+57
View File
@@ -0,0 +1,57 @@
# Fork Changelog
This file tracks fork-specific changes that are useful to operators and contributors. It is not meant to replace commit history, but to summarize what changed at a higher level.
## 0.6.3
- Added JDBC-backed playback analytics logging.
- Added SQLite as the default logging backend.
- Logged queue, playback, listener, search, skip, and command activity.
- Added `dblog.*` config settings to enable and configure logging.
Config:
```conf
dblog.enabled = true
dblog.jdbc_url = "jdbc:sqlite:musicbot.db"
dblog.user = ""
dblog.password = ""
```
By default, logging is disabled.
## 0.6.2
- Fixed several status-message race conditions and repost issues.
- Added debug logging around status-message creation, editing, deletion, and repost flow.
- Added a config toggle to disable the status-message feature entirely.
Config:
```conf
statusmessages = true
```
Set it to `false` to disable the feature.
## 0.6.1
- Bumped the fork version after the initial status-message work had landed.
## 0.6.0
- Updated the bot for modern JDA and Discord requirements.
- Added DAVE voice support required for current Discord voice playback.
- Continued dependency refreshes for lavaplayer and related components.
- Carried forward work related to YouTube login, PO token handling, and buildability on the newer stack.
Imported work included in this fork:
- PR-1670, support for logging into a YouTube account, is included from `0.4.5` onward.
- PR-1703, changes to allow building the project from source more easily, is included from `0.4.5` onward.
- `jagrosh#1772`, YouTube PO token and IP rotation support, is included from `0.4.7` onward.
## Notes
- This fork may move faster than upstream in areas related to Discord API compatibility and deployment practicality.
- When reporting issues, mention the exact fork version because behavior around playback, status messages, and logging has changed across recent releases.
+178
View File
@@ -0,0 +1,178 @@
# Setup Guide
This guide covers the normal setup path and the main configuration points that matter on this fork.
## Prerequisites
- Java 11 or newer
- A Discord bot token
- A Discord application with the bot invited to your server
## Discord Setup
1. Create a bot in the [Discord Developer Portal](https://discord.com/developers/applications).
2. Copy the bot token.
3. Enable the `MESSAGE_CONTENT` intent.
This intent is required for command-based bots like this one.
## Privileged Intent Notes
This fork moved from JDA 4 to JDA 6, so `MESSAGE_CONTENT` is now an important operational requirement for the classic text-command flow.
Keep in mind:
- If the bot joins more than 100 servers, this intent must be enabled in the [Discord Developer Portal](https://discord.com/developers/applications).
- `MESSAGE_CONTENT` is a privileged intent.
- Discord verification requirements become relevant as the bot grows.
- The related portal controls historically only become visible once the application is already in a fairly large number of servers.
For the broader Discord/JDA behavior, see the [JDA gateway intents documentation](https://jda.wiki/using-jda/gateway-intents-and-member-cache-policy/).
## First Configuration
The bot reads from `config.txt` using the values defined in [reference.conf](/git/MusicBot/src/main/resources/reference.conf).
The minimum settings you should care about first are:
```conf
token = "YOUR_BOT_TOKEN"
owner = 123456789012345678
prefix = "@mention"
```
Useful fork-specific options:
```conf
youtubeoauth2 = true
statusmessages = true
dblog.enabled = false
```
You may also need these for YouTube PO token support:
```conf
ytpotoken = "PO_TOKEN_HERE"
ytvisitordata = "VISITOR_DATA_HERE"
```
## Running The Bot
Build:
```bash
./mvnw clean package
```
Run:
```bash
java -jar target/JMusicBot-*-All.jar
```
This fork includes changes to keep source builds practical on the newer stack as well.
## YouTube Login
If YouTube playback is unreliable, enable:
```conf
youtubeoauth2 = true
```
After startup, the bot prints instructions and sends the owner a DM with the authorization flow.
Important:
- Do not use your main Google account.
- Use a burner or otherwise disposable account.
If you are starting from a clean config, this option should already exist with a default of `false`.
## YouTube PO Tokens And Visitor Data
This fork also supports explicit YouTube PO token and visitor-data configuration.
Config:
```conf
ytpotoken = "PO_TOKEN_HERE"
ytvisitordata = "VISITOR_DATA_HERE"
```
You can generate these values with:
- [iv-org/youtube-trusted-session-generator](https://github.com/iv-org/youtube-trusted-session-generator)
This is useful when normal YouTube playback is not enough and you need a more explicit trusted-session style setup.
## DAVE / Voice Support
This fork includes DAVE support for current Discord voice requirements. In normal supported environments, nothing extra is required beyond using the provided shaded jar.
If you run on an unsupported architecture, add the correct native `libdave-jvm` dependency in [pom.xml](/git/MusicBot/pom.xml).
Bundled native targets include:
- Linux glibc x86_64
- Linux musl x86_64
- Windows x86_64
- macOS
## Status Messages
The bot can maintain a single playback status message in the active text channel.
Config:
```conf
statusmessages = true
```
Disable it with:
```conf
statusmessages = false
```
## Analytics Logging
Playback analytics logging is off by default.
Enable SQLite logging:
```conf
dblog.enabled = true
dblog.jdbc_url = "jdbc:sqlite:musicbot.db"
```
This creates a local SQLite database file and records queue, playback, listener, and command activity.
When analytics are enabled, the `stats` command can show server, personal, track, source, and skip reports:
```text
stats server month
stats me week source:youtube
stats track
stats tracks 60d limit:20 userlist:[alice,bob]
stats listened 1y users:alice
stats reports set #music-stats monthly
stats reports now 2026-04
stats reports now 2025
```
Personal member reports are self-only unless the requester has Manage Server. Scheduled reports are Discord embeds; graphic rewind images are not generated yet.
## Windows Note
If you run the bot on Windows, install Java first and consider a service wrapper such as [NSSM](http://nssm.cc/) if you want it to run in the background.
## Related Fork Context
This fork incorporates or builds on work related to:
- [PR-1670](https://github.com/jagrosh/MusicBot/pull/1670) for YouTube account login support
- [PR-1703](https://github.com/jagrosh/MusicBot/pull/1703) for source builds
- [PR-1772](https://github.com/jagrosh/MusicBot/pull/1772) for YouTube PO token and IP rotation work
If this fork does not fit your setup, another fork you may want to inspect is [SeVile/MusicBot](https://github.com/SeVile/MusicBot).
Vendored Executable
+295
View File
@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you 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.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
+189
View File
@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+133 -22
View File
@@ -3,10 +3,15 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.jagrosh</groupId>
<artifactId>JMusicBot</artifactId>
<version>0.5.0</version>
<version>0.6.7</version>
<packaging>jar</packaging>
<repositories>
<repository>
<id>m2-chew</id>
<name>Maven Chewtral</name>
<url>https://m2.chew.pro/releases</url>
</repository>
<repository>
<id>dv8tion</id>
<name>m2-dv8tion</name>
@@ -14,14 +19,17 @@
</repository>
<repository>
<id>central</id>
<name>bintray</name>
<url>https://jcenter.bintray.com</url>
<name>Maven Central</name>
<url>https://repo.maven.apache.org/maven2</url>
</repository>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
<snapshots>
<releases>
<enabled>true</enabled>
</releases>
<snapshots>
<enabled>false</enabled>
<updatePolicy>always</updatePolicy>
</snapshots>
</repository>
@@ -47,19 +55,103 @@
<dependency>
<groupId>net.dv8tion</groupId>
<artifactId>JDA</artifactId>
<version>6.1.1</version>
<version>6.3.1</version>
</dependency>
<dependency>
<groupId>com.github.Chew</groupId>
<artifactId>JDA-Chewtils</artifactId>
<version>fix~components-v2-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>moe.kyokobot.libdave</groupId>
<artifactId>adapter-jda</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>moe.kyokobot.libdave</groupId>
<artifactId>impl-jni</artifactId>
<version>0.1.0</version>
</dependency>
<dependency>
<groupId>moe.kyokobot.libdave</groupId>
<artifactId>natives-linux-x86-64</artifactId>
<version>0.1.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>moe.kyokobot.libdave</groupId>
<artifactId>natives-linux-musl-x86-64</artifactId>
<version>0.1.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>moe.kyokobot.libdave</groupId>
<artifactId>natives-win-x86-64</artifactId>
<version>0.1.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>moe.kyokobot.libdave</groupId>
<artifactId>natives-darwin</artifactId>
<version>0.1.0</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-command</artifactId>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-commons</artifactId>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-examples</artifactId>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-menu</artifactId>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<!-- <dependency>
<groupId>com.github.chew.jda-chewtils</groupId>
<artifactId>jda-chewtils-command</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>com.github.chew.jda-chewtils</groupId>
<artifactId>jda-chewtils-menu</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>com.github.chew.jda-chewtils</groupId>
<artifactId>jda-chewtils-commons</artifactId>
<version>2.2.1</version>
</dependency> -->
<!--
<dependency>
<groupId>com.github.chew.jda-chewtils</groupId>
<artifactId>jda-chewtils-command</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>com.github.chew.jda-chewtils</groupId>
<artifactId>jda-chewtils-menu</artifactId>
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>com.github.chew.jda-chewtils</groupId>
<artifactId>jda-chewtils-commons</artifactId>
<version>2.2.1</version>
</dependency>
-->
<!-- Music Dependencies -->
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavaplayer</artifactId>
<version>2.2.4</version>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>dev.arbjerg</groupId>
@@ -69,12 +161,12 @@
<dependency>
<groupId>dev.lavalink.youtube</groupId>
<artifactId>common</artifactId>
<version>1.16.0</version>
<version>f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.github.jagrosh</groupId>
<artifactId>JLyrics</artifactId>
<version>master-SNAPSHOT</version>
<version>master</version>
</dependency>
<dependency>
<groupId>com.dunctebot</groupId>
@@ -88,6 +180,16 @@
<artifactId>logback-classic</artifactId>
<version>1.5.21</version>
</dependency>
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.1.0</version>
</dependency>
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20240303</version>
</dependency>
<dependency>
<groupId>com.typesafe</groupId>
<artifactId>config</artifactId>
@@ -96,25 +198,22 @@
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.15.3</version>
<version>1.14.2</version>
</dependency>
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.27.0</version>
<version>4.49.0</version>
<!-- https://github.com/SeleniumHQ/selenium/issues/13791 -->
<!-- selenium-java 4.49.0 ships CDP v151/v152/v153; keep only the one matching the installed Chromium -->
<exclusions>
<exclusion>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-devtools-v85</artifactId>
<artifactId>selenium-devtools-v151</artifactId>
</exclusion>
<exclusion>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-devtools-v129</artifactId>
</exclusion>
<exclusion>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-devtools-v130</artifactId>
<artifactId>selenium-devtools-v152</artifactId>
</exclusion>
</exclusions>
</dependency>
@@ -142,6 +241,16 @@
</dependencies>
<build>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>false</filtering>
</resource>
<resource>
<directory>src/main/filtered-resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
@@ -162,6 +271,7 @@
</includes>
</artifactSet>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer"/>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>reference.conf</resource>
</transformer>
@@ -185,6 +295,7 @@
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<jda.chewtils.version>2.2.1</jda.chewtils.version>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
@@ -0,0 +1 @@
jda-chewtils.version=${jda.chewtils.version}
+41 -1
View File
@@ -22,6 +22,9 @@ import com.jagrosh.jmusicbot.audio.AloneInVoiceHandler;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.NowplayingHandler;
import com.jagrosh.jmusicbot.audio.PlayerManager;
import com.jagrosh.jmusicbot.audio.StatusMessageHandler;
import com.jagrosh.jmusicbot.datalog.DataLogService;
import com.jagrosh.jmusicbot.datalog.StatsReportScheduler;
import com.jagrosh.jmusicbot.gui.GUI;
import com.jagrosh.jmusicbot.playlist.PlaylistLoader;
import com.jagrosh.jmusicbot.settings.SettingsManager;
@@ -44,7 +47,10 @@ public class Bot
private final PlayerManager players;
private final PlaylistLoader playlists;
private final NowplayingHandler nowplaying;
private final StatusMessageHandler statusMessageHandler;
private final AloneInVoiceHandler aloneInVoiceHandler;
private final DataLogService dataLogService;
private final StatsReportScheduler statsReportScheduler;
private final YoutubeOauth2TokenHandler youTubeOauth2TokenHandler;
private boolean shuttingDown = false;
@@ -64,8 +70,25 @@ public class Bot
this.players.init();
this.nowplaying = new NowplayingHandler(this);
this.nowplaying.init();
this.statusMessageHandler = new StatusMessageHandler(this);
this.statusMessageHandler.init();
this.aloneInVoiceHandler = new AloneInVoiceHandler(this);
this.aloneInVoiceHandler.init();
DataLogService dls = null;
if(config.getDataLogEnabled())
{
try
{
dls = new DataLogService(this, config.getDataLogJdbcUrl(), config.getDataLogUser(), config.getDataLogPassword());
}
catch(Exception ex)
{
dls = null;
}
}
this.dataLogService = dls;
this.statsReportScheduler = new StatsReportScheduler(this);
this.statsReportScheduler.init();
}
public BotConfig getConfig()
@@ -103,11 +126,26 @@ public class Bot
return nowplaying;
}
public StatusMessageHandler getStatusMessageHandler()
{
return statusMessageHandler;
}
public AloneInVoiceHandler getAloneInVoiceHandler()
{
return aloneInVoiceHandler;
}
public DataLogService getDataLogService()
{
return dataLogService;
}
public StatsReportScheduler getStatsReportScheduler()
{
return statsReportScheduler;
}
public YoutubeOauth2TokenHandler getYouTubeOauth2Handler()
{
return youTubeOauth2TokenHandler;
@@ -138,7 +176,7 @@ public class Bot
return;
shuttingDown = true;
threadpool.shutdownNow();
if(jda.getStatus()!=JDA.Status.SHUTTING_DOWN)
if(jda != null && jda.getStatus()!=JDA.Status.SHUTTING_DOWN)
{
jda.getGuilds().stream().forEach(g ->
{
@@ -154,6 +192,8 @@ public class Bot
}
if(gui!=null)
gui.dispose();
if(dataLogService != null)
dataLogService.shutdown();
System.exit(0);
}
@@ -50,6 +50,9 @@ public class BotConfig
private YouTubeUtil.RoutingPlanner ytRoutingPlanner;
private List<IpBlock> ytIpBlocks;
private boolean youtubeOauth2, stayInChannel, songInGame, npImages, updatealerts, chromeHeadless, useEval, dbots;
private boolean statusMessages;
private boolean dataLogEnabled;
private String dataLogJdbcUrl, dataLogUser, dataLogPassword;
private long owner, maxSeconds, aloneTimeUntilStop;
private int maxYTPlaylistPages;
private double skipratio;
@@ -94,6 +97,7 @@ public class BotConfig
youtubeOauth2 = config.getBoolean("youtubeoauth2");
stayInChannel = config.getBoolean("stayinchannel");
songInGame = config.getBoolean("songinstatus");
statusMessages = config.getBoolean("statusmessages");
npImages = config.getBoolean("npimages");
updatealerts = config.getBoolean("updatealerts");
logLevel = config.getString("loglevel");
@@ -112,6 +116,10 @@ public class BotConfig
transforms = config.getConfig("transforms");
skipratio = config.getDouble("skipratio");
dbots = owner == 113156185389092864L;
dataLogEnabled = config.getBoolean("dblog.enabled");
dataLogJdbcUrl = config.getString("dblog.jdbc_url");
dataLogUser = config.getString("dblog.user");
dataLogPassword = config.getString("dblog.password");
// we may need to write a new config file
boolean write = false;
@@ -260,6 +268,26 @@ public class BotConfig
{
return owner;
}
public boolean getDataLogEnabled()
{
return dataLogEnabled;
}
public String getDataLogJdbcUrl()
{
return dataLogJdbcUrl;
}
public String getDataLogUser()
{
return dataLogUser;
}
public String getDataLogPassword()
{
return dataLogPassword;
}
public String getSuccess()
{
@@ -320,6 +348,11 @@ public class BotConfig
{
return songInGame;
}
public boolean useStatusMessages()
{
return statusMessages;
}
public String getPlaylistsFolder()
{
@@ -18,7 +18,8 @@ package com.jagrosh.jmusicbot;
import com.jagrosh.jdautilities.command.CommandClient;
import com.jagrosh.jdautilities.command.CommandClientBuilder;
import com.jagrosh.jdautilities.commons.waiter.EventWaiter;
import com.jagrosh.jdautilities.examples.command.*;
import com.jagrosh.jmusicbot.commands.general.LoggedAboutCmd;
import com.jagrosh.jmusicbot.commands.general.LoggedPingCmd;
import com.jagrosh.jmusicbot.commands.admin.*;
import com.jagrosh.jmusicbot.commands.dj.*;
import com.jagrosh.jmusicbot.commands.general.*;
@@ -28,10 +29,13 @@ import com.jagrosh.jmusicbot.entities.Prompt;
import com.jagrosh.jmusicbot.gui.GUI;
import com.jagrosh.jmusicbot.settings.SettingsManager;
import com.jagrosh.jmusicbot.utils.OtherUtil;
import moe.kyokobot.libdave.NativeDaveFactory;
import moe.kyokobot.libdave.jda.LDJDADaveSessionFactory;
import java.awt.Color;
import java.util.Arrays;
import javax.security.auth.login.LoginException;
import net.dv8tion.jda.api.*;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.requests.GatewayIntent;
import net.dv8tion.jda.api.utils.cache.CacheFlag;
@@ -52,7 +56,7 @@ public class JMusicBot
public final static Permission[] RECOMMENDED_PERMS = {Permission.VIEW_CHANNEL, Permission.MESSAGE_SEND, Permission.MESSAGE_HISTORY, Permission.MESSAGE_ADD_REACTION,
Permission.MESSAGE_EMBED_LINKS, Permission.MESSAGE_ATTACH_FILES, Permission.MESSAGE_MANAGE, Permission.MESSAGE_EXT_EMOJI,
Permission.VOICE_CONNECT, Permission.VOICE_SPEAK, Permission.NICKNAME_CHANGE};
public final static GatewayIntent[] INTENTS = {GatewayIntent.DIRECT_MESSAGES, GatewayIntent.GUILD_MESSAGES, GatewayIntent.GUILD_MESSAGE_REACTIONS, GatewayIntent.GUILD_VOICE_STATES};
public final static GatewayIntent[] INTENTS = {GatewayIntent.DIRECT_MESSAGES, GatewayIntent.GUILD_MESSAGES, GatewayIntent.GUILD_MESSAGE_REACTIONS, GatewayIntent.GUILD_VOICE_STATES, GatewayIntent.MESSAGE_CONTENT, GatewayIntent.GUILD_EXPRESSIONS, GatewayIntent.SCHEDULED_EVENTS};
/**
* @param args the command line arguments
@@ -121,10 +125,10 @@ public class JMusicBot
JDA jda = JDABuilder.create(config.getToken(), Arrays.asList(INTENTS))
.enableCache(CacheFlag.MEMBER_OVERRIDES, CacheFlag.VOICE_STATE)
.disableCache(CacheFlag.ACTIVITY, CacheFlag.CLIENT_STATUS, CacheFlag.ONLINE_STATUS)
.enableIntents(GatewayIntent.MESSAGE_CONTENT)
.setActivity(config.isGameNone() ? null : Activity.playing("loading..."))
.setStatus(config.getStatus()==OnlineStatus.INVISIBLE || config.getStatus()==OnlineStatus.OFFLINE
? OnlineStatus.INVISIBLE : OnlineStatus.DO_NOT_DISTURB)
.setAudioModuleConfig(createAudioModuleConfig())
.addEventListeners(client, waiter, new Listener(bot))
.setBulkDeleteSplittingEnabled(true)
.build();
@@ -150,6 +154,12 @@ public class JMusicBot
+ "on https://discord.com/developers/applications/" + jda.getSelfUser().getId() + "/bot");
}
}
catch(IllegalStateException ex)
{
prompt.alert(Prompt.Level.ERROR, "JMusicBot", ex.getMessage());
LOG.error("Failed to initialize DAVE protocol support", ex);
System.exit(1);
}
catch(IllegalArgumentException ex)
{
prompt.alert(Prompt.Level.ERROR, "JMusicBot", "Some aspect of the configuration is "
@@ -165,12 +175,28 @@ public class JMusicBot
GetPoTokenAndVisitorData(config.getChromePath(), config.getChromeDriverPath(), config.getChromeHeadless());
}
private static AudioModuleConfig createAudioModuleConfig()
{
try
{
NativeDaveFactory.ensureAvailable();
LOG.info("Configured DAVE protocol support using libdave-jvm.");
return new AudioModuleConfig()
.withDaveSessionFactory(new LDJDADaveSessionFactory(new NativeDaveFactory()));
}
catch(Throwable ex)
{
throw new IllegalStateException("Unable to initialize DAVE protocol support via libdave-jvm. "
+ "Verify native dependencies are present for this platform (Linux glibc/musl x86_64, Windows x86_64, or macOS).", ex);
}
}
private static CommandClient createCommandClient(BotConfig config, SettingsManager settings, Bot bot)
{
// instantiate about command
AboutCommand aboutCommand = new AboutCommand(Color.BLUE.brighter(),
"a music bot that is [easy to host yourself!](https://github.com/jagrosh/MusicBot) (v" + OtherUtil.getCurrentVersion() + ")",
LoggedAboutCmd aboutCommand = new LoggedAboutCmd(bot, Color.BLUE.brighter(),
"a music bot that is [easy to host yourself!](https://github.com/chrisb09/MusicBot) (v" + OtherUtil.getCurrentVersion() + ")",
new String[]{"High-quality music playback", "FairQueue™ Technology", "Easy to host yourself"},
RECOMMENDED_PERMS);
aboutCommand.setIsAuthor(false);
@@ -186,8 +212,9 @@ public class JMusicBot
.setLinkedCacheSize(200)
.setGuildSettingsManager(settings)
.addCommands(aboutCommand,
new PingCommand(),
new LoggedPingCmd(bot),
new SettingsCmd(bot),
new StatsCmd(bot),
new LyricsCmd(bot),
new NowplayingCmd(bot),
@@ -28,6 +28,7 @@ import net.dv8tion.jda.api.events.session.ShutdownEvent;
import net.dv8tion.jda.api.events.guild.GuildJoinEvent;
import net.dv8tion.jda.api.events.guild.voice.GuildVoiceUpdateEvent;
import net.dv8tion.jda.api.events.message.MessageDeleteEvent;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.hooks.ListenerAdapter;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
@@ -108,14 +109,24 @@ public class Listener extends ListenerAdapter
@Override
public void onMessageDelete(MessageDeleteEvent event)
{
if(event.isFromGuild())
if(event.isFromGuild()) {
bot.getNowplayingHandler().onMessageDelete(event.getGuild(), event.getMessageIdLong());
bot.getStatusMessageHandler().onMessageDelete(event.getGuild(), event.getMessageIdLong());
}
}
@Override
public void onMessageReceived(MessageReceivedEvent event)
{
bot.getStatusMessageHandler().onMessageReceived(event);
}
@Override
public void onGuildVoiceUpdate(@NotNull GuildVoiceUpdateEvent event)
{
bot.getAloneInVoiceHandler().onVoiceUpdate(event);
if(bot.getDataLogService() != null)
bot.getDataLogService().onVoiceUpdate(event);
}
@Override
@@ -40,7 +40,6 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.api.audio.AudioSendHandler;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -66,6 +65,8 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
private AudioFrame lastFrame;
private AbstractQueue<QueuedTrack> queue;
private volatile String playCountTrackIdentifier;
private volatile long currentTrackServerPlayCount = -1L;
protected AudioHandler(PlayerManager manager, Guild guild, AudioPlayer player)
{
@@ -158,10 +159,13 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
return false;
pl.loadTracks(manager, (at) ->
{
at.setUserData(RequestMetadata.fromDefaultPlaylist(settings.getDefaultPlaylist()));
if(audioPlayer.getPlayingTrack()==null)
audioPlayer.playTrack(at);
else
defaultQueue.add(at);
if(manager.getBot().getDataLogService() != null)
manager.getBot().getDataLogService().logQueueAdd(guild(manager.getBot().getJDA()), null, at, "DEFAULT_PLAYLIST", null, null, settings.getDefaultPlaylist());
}, () ->
{
if(pl.getTracks().isEmpty() && !manager.getBot().getConfig().getStay())
@@ -174,6 +178,8 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
@Override
public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason)
{
if(manager.getBot().getDataLogService() != null)
manager.getBot().getDataLogService().logPlayEnd(guild(manager.getBot().getJDA()), endReason.name());
RepeatMode repeatMode = manager.getBot().getSettingsManager().getSettings(guildId).getRepeatMode();
// if the track ended normally, and we're in repeat mode, re-add it to the queue
if(endReason==AudioTrackEndReason.FINISHED && repeatMode != RepeatMode.OFF)
@@ -190,6 +196,7 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
if(!playFromDefault())
{
manager.getBot().getNowplayingHandler().onTrackUpdate(null);
manager.getBot().getStatusMessageHandler().onTrackUpdate(guildId);
if(!manager.getBot().getConfig().getStay())
manager.getBot().closeAudioConnection(guildId);
// unpause, in the case when the player was paused and the track has been skipped.
@@ -226,6 +233,22 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
{
votes.clear();
manager.getBot().getNowplayingHandler().onTrackUpdate(track);
manager.getBot().getStatusMessageHandler().onTrackUpdate(guildId);
if(manager.getBot().getDataLogService() != null)
{
RequestMetadata rm = getRequestMetadata();
String source = rm.queueInfo == null ? null : rm.queueInfo.source;
String searchQuery = rm.queueInfo == null ? null : rm.queueInfo.searchQuery;
String playlistName = rm.queueInfo == null ? null : rm.queueInfo.playlistName;
manager.getBot().getDataLogService().logPlayStart(guild(manager.getBot().getJDA()), track, rm, source, searchQuery, playlistName);
playCountTrackIdentifier = track.getIdentifier();
currentTrackServerPlayCount = manager.getBot().getDataLogService().getGuildTrackPlayCount(guild(manager.getBot().getJDA()), track);
}
else
{
playCountTrackIdentifier = null;
currentTrackServerPlayCount = -1L;
}
}
@@ -272,6 +295,8 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
+ " "+FormatUtil.progressBar(progress)
+ " `[" + TimeUtil.formatTime(track.getPosition()) + "/" + TimeUtil.formatTime(track.getDuration()) + "]` "
+ FormatUtil.volumeIcon(audioPlayer.getVolume()));
if(currentTrackServerPlayCount > 0L && track.getIdentifier().equals(playCountTrackIdentifier))
eb.addField("Server plays", "Played " + currentTrackServerPlayCount + " time" + (currentTrackServerPlayCount == 1L ? "" : "s") + " on this server", false);
return mb.setEmbeds(eb.build()).build();
}
@@ -290,6 +315,87 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
.build()).build();
}
public MessageCreateData getStatusMessage(JDA jda)
{
Guild guild = guild(jda);
if(guild == null)
return null;
MessageCreateBuilder mb = new MessageCreateBuilder();
mb.setContent(FormatUtil.filter(manager.getBot().getConfig().getSuccess()+" **Now Playing...**"));
EmbedBuilder eb = new EmbedBuilder();
eb.setColor(guild.getSelfMember().getColor());
AudioTrack track = audioPlayer.getPlayingTrack();
if(isMusicPlaying(jda) && track != null)
{
try
{
eb.setTitle(track.getInfo().title, track.getInfo().uri);
}
catch(Exception e)
{
eb.setTitle(track.getInfo().title);
}
}
else
{
eb.setTitle("No music playing");
}
StringBuilder desc = new StringBuilder();
if(isMusicPlaying(jda) && track != null)
{
double progress = (double)track.getPosition()/track.getDuration();
desc.append(getStatusEmoji())
.append(" ").append(FormatUtil.progressBar(progress))
.append(" `[").append(TimeUtil.formatTime(track.getPosition()))
.append("/").append(TimeUtil.formatTime(track.getDuration())).append("]` ")
.append(FormatUtil.volumeIcon(audioPlayer.getVolume()));
if(currentTrackServerPlayCount > 0L && track.getIdentifier().equals(playCountTrackIdentifier))
desc.append("\nPlayed ").append(currentTrackServerPlayCount).append(" time")
.append(currentTrackServerPlayCount == 1L ? "" : "s").append(" on this server");
}
else
{
desc.append(STOP_EMOJI)
.append(" ").append(FormatUtil.progressBar(-1))
.append(" ").append(FormatUtil.volumeIcon(audioPlayer.getVolume()));
}
List<QueuedTrack> list = queue.getList();
if(!list.isEmpty())
{
QueuedTrack next = list.get(0);
String nextTitle = next.getTrack().getInfo().title;
String requester = next.getRequestMetadata() == null || next.getRequestMetadata().user == null
? null
: FormatUtil.formatUsername(next.getRequestMetadata().user);
desc.append("\n").append("Next: **").append(nextTitle).append("**");
if(requester != null && !requester.isEmpty())
desc.append(" (by ").append(requester).append(")");
}
long total = 0L;
for(QueuedTrack qt : list)
total += qt.getTrack().getDuration();
Settings settings = manager.getBot().getSettingsManager().getSettings(guildId);
desc.append("\n")
.append(manager.getBot().getConfig().getSuccess())
.append(" Queue | ").append(list.size()).append(" entries | `")
.append(TimeUtil.formatTime(total)).append("` | ")
.append(settings.getQueueType().getEmoji()).append(" `")
.append(settings.getQueueType().getUserFriendlyName()).append("`");
if(settings.getRepeatMode().getEmoji() != null)
desc.append(" | ").append(settings.getRepeatMode().getEmoji());
if(!guild.getSelfMember().getVoiceState().inAudioChannel())
desc.append("\n").append(manager.getBot().getConfig().getWarning()).append(" Disconnected");
eb.setDescription(FormatUtil.filter(desc.toString()));
return mb.setEmbeds(eb.build()).build();
}
public String getStatusEmoji()
{
return audioPlayer.isPaused() ? PAUSE_EMOJI : PLAY_EMOJI;
@@ -40,7 +40,17 @@ import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.IpBlock;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv4Block;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv6Block;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.clients.Android;
import dev.lavalink.youtube.clients.AndroidMusic;
import dev.lavalink.youtube.clients.AndroidVr;
import dev.lavalink.youtube.clients.ClientOptions;
import dev.lavalink.youtube.clients.Ios;
import dev.lavalink.youtube.clients.MWeb;
import dev.lavalink.youtube.clients.Music;
import dev.lavalink.youtube.clients.Tv;
import dev.lavalink.youtube.clients.TvHtml5Simply;
import dev.lavalink.youtube.clients.Web;
import dev.lavalink.youtube.clients.WebEmbedded;
import net.dv8tion.jda.api.entities.Guild;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -68,7 +78,7 @@ public class PlayerManager extends DefaultAudioPlayerManager
{
TransformativeAudioSourceManager.createTransforms(bot.getConfig().getTransforms()).forEach(t -> registerSourceManager(t));
YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(true);
YoutubeAudioSourceManager yt = setupYoutubeAudioSourceManager();
if (config.getYTRoutingPlanner() != YouTubeUtil.RoutingPlanner.NONE)
{
AbstractRoutePlanner routePlanner = YouTubeUtil.createRouterPlanner(config.getYTRoutingPlanner(), config.getYTIpBlocks());
@@ -85,7 +95,15 @@ public class PlayerManager extends DefaultAudioPlayerManager
registerSourceManager(SoundCloudAudioSourceManager.createDefault());
registerSourceManager(new BandcampAudioSourceManager());
registerSourceManager(new VimeoAudioSourceManager());
registerSourceManager(new TwitchStreamAudioSourceManager());
try
{
registerSourceManager(new TwitchStreamAudioSourceManager());
}
catch(Exception e)
{
LOGGER.warn("Skipping Twitch source initialization because Twitch lookup failed during startup: {}", e.getMessage());
LOGGER.debug("Twitch source initialization failure:", e);
}
registerSourceManager(new BeamAudioSourceManager());
registerSourceManager(new GetyarnAudioSourceManager());
registerSourceManager(new NicoAudioSourceManager());
@@ -98,7 +116,20 @@ public class PlayerManager extends DefaultAudioPlayerManager
private YoutubeAudioSourceManager setupYoutubeAudioSourceManager()
{
YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(true);
ClientOptions playbackOnly = new ClientOptions();
playbackOnly.setSearching(false);
YoutubeAudioSourceManager yt = new YoutubeAudioSourceManager(true,
new Ios(playbackOnly),
new Music(playbackOnly),
new AndroidMusic(playbackOnly),
new TvHtml5Simply(playbackOnly),
new Tv(playbackOnly),
new Web(),
new MWeb(),
new WebEmbedded(),
new AndroidVr(playbackOnly),
new Android(playbackOnly));
yt.setPlaylistPageCount(bot.getConfig().getMaxYTPlaylistPages());
// OAuth2 setup
@@ -29,15 +29,17 @@ import java.util.regex.Pattern;
*/
public class RequestMetadata
{
public static final RequestMetadata EMPTY = new RequestMetadata(null, null);
public static final RequestMetadata EMPTY = new RequestMetadata(null, null, null);
public final UserInfo user;
public final RequestInfo requestInfo;
public final QueueInfo queueInfo;
public RequestMetadata(User user, RequestInfo requestInfo)
public RequestMetadata(User user, RequestInfo requestInfo, QueueInfo queueInfo)
{
this.user = user == null ? null : new UserInfo(user.getIdLong(), user.getName(), user.getDiscriminator(), user.getEffectiveAvatarUrl());
this.requestInfo = requestInfo;
this.queueInfo = queueInfo;
}
public long getOwner()
@@ -47,7 +49,20 @@ public class RequestMetadata
public static RequestMetadata fromResultHandler(AudioTrack track, CommandEvent event)
{
return new RequestMetadata(event.getAuthor(), new RequestInfo(event.getArgs(), track.getInfo().uri));
return new RequestMetadata(event.getAuthor(), new RequestInfo(event.getArgs(), track.getInfo().uri),
new QueueInfo("PLAY", null, event.getArgs(), System.currentTimeMillis()));
}
public static RequestMetadata fromResultHandler(AudioTrack track, CommandEvent event, String source, String playlistName, String searchQuery)
{
return new RequestMetadata(event.getAuthor(), new RequestInfo(event.getArgs(), track.getInfo().uri),
new QueueInfo(source, playlistName, searchQuery, System.currentTimeMillis()));
}
public static RequestMetadata fromDefaultPlaylist(String playlistName)
{
return new RequestMetadata(null, new RequestInfo(playlistName, null),
new QueueInfo("DEFAULT_PLAYLIST", playlistName, null, System.currentTimeMillis()));
}
public static class RequestInfo
@@ -70,10 +85,28 @@ public class RequestMetadata
private static final Pattern youtubeTimestampPattern = Pattern.compile("youtu(?:\\.be|be\\..+)/.*\\?.*(?!.*list=)t=([\\dhms]+)");
private static long tryGetTimestamp(String url)
{
if(url == null || url.isEmpty())
return 0;
Matcher matcher = youtubeTimestampPattern.matcher(url);
return matcher.find() ? TimeUtil.parseUnitTime(matcher.group(1)) : 0;
}
}
public static class QueueInfo
{
public final String source;
public final String playlistName;
public final String searchQuery;
public final long queuedAt;
public QueueInfo(String source, String playlistName, String searchQuery, long queuedAt)
{
this.source = source;
this.playlistName = playlistName;
this.searchQuery = searchQuery;
this.queuedAt = queuedAt;
}
}
public static class UserInfo
{
@@ -0,0 +1,309 @@
/*
* Copyright 2026
*
* 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.
*/
package com.jagrosh.jmusicbot.audio;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.settings.Settings;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.unions.MessageChannelUnion;
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
import net.dv8tion.jda.api.exceptions.ErrorResponseException;
import net.dv8tion.jda.api.requests.ErrorResponse;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.api.utils.messages.MessageEditData;
public class StatusMessageHandler
{
private static final Logger LOG = LoggerFactory.getLogger("StatusMessage");
private final Bot bot;
private final Map<Long, StatusState> states;
public StatusMessageHandler(Bot bot)
{
this.bot = bot;
this.states = new ConcurrentHashMap<>();
}
public void init()
{
if(!bot.getConfig().useStatusMessages())
return;
bot.getThreadpool().scheduleWithFixedDelay(() -> updateAll(), 0, 1, TimeUnit.SECONDS);
}
public void onMessageReceived(MessageReceivedEvent event)
{
if(!bot.getConfig().useStatusMessages())
return;
if(!event.isFromGuild())
return;
Guild guild = event.getGuild();
StatusState state = states.computeIfAbsent(guild.getIdLong(), id -> new StatusState());
synchronized(state)
{
MessageChannelUnion channelUnion = event.getChannel();
if(channelUnion == null || channelUnion.getType() != ChannelType.TEXT)
return;
TextChannel channel = channelUnion.asTextChannel();
boolean isCommand = isCommandMessage(event.getMessage(), guild);
Settings settings = bot.getSettingsManager().getSettings(guild);
if(isCommand && !event.getAuthor().isBot() && settings.getTextChannel(guild) == null)
state.lastCommandChannelId = channel.getIdLong();
Long statusChannelId = resolveStatusChannelId(guild, state);
if(statusChannelId == null || statusChannelId == 0L)
return;
if(isCommand && !event.getAuthor().isBot() && channel.getIdLong() == statusChannelId)
state.pendingRepost = true;
if(channel.getIdLong() != statusChannelId)
return;
if(state.messageId != 0L && event.getMessageIdLong() == state.messageId)
return;
if(state.pendingRepost)
{
long selfId = bot.getJDA().getSelfUser().getIdLong();
if(event.getAuthor().getIdLong() == selfId)
{
debug(guild, state, "pending_repost_trigger", "author_is_self", event.getMessageIdLong());
repostStatusMessage(guild, channel, state);
state.pendingRepost = false;
}
return;
}
// Avoid repost loops when the status message itself is created
if(event.getAuthor().getIdLong() == bot.getJDA().getSelfUser().getIdLong() && state.createInFlight)
return;
debug(guild, state, "repost_due_to_message", "message_author", event.getAuthor().getIdLong());
repostStatusMessage(guild, channel, state);
}
}
public void onMessageDelete(Guild guild, long messageId)
{
if(!bot.getConfig().useStatusMessages())
return;
StatusState state = states.get(guild.getIdLong());
if(state == null)
return;
synchronized(state)
{
if(state.createInFlight)
{
debug(guild, state, "delete_ignored_in_flight", "message", messageId);
return;
}
if(state.messageId == messageId)
state.messageId = 0L;
}
}
public void onTrackUpdate(long guildId)
{
if(!bot.getConfig().useStatusMessages())
return;
StatusState state = states.computeIfAbsent(guildId, id -> new StatusState());
state.forceUpdate = true;
}
private void updateAll()
{
if(!bot.getConfig().useStatusMessages())
return;
JDA jda = bot.getJDA();
if(jda == null)
return;
for(Map.Entry<Long, StatusState> entry : states.entrySet())
{
long guildId = entry.getKey();
StatusState state = entry.getValue();
synchronized(state)
{
Guild guild = jda.getGuildById(guildId);
if(guild == null)
continue;
TextChannel channel = resolveStatusChannel(guild, state);
if(channel == null)
continue;
AudioHandler handler = (AudioHandler)guild.getAudioManager().getSendingHandler();
if(handler == null)
continue;
if(state.createInFlight)
continue;
boolean isPlaying = handler.isMusicPlaying(jda);
if(!isPlaying && !state.forceUpdate)
continue;
if(state.pendingRepost && (state.messageId == 0L || state.channelId != channel.getIdLong()))
continue;
MessageCreateData statusMessage = handler.getStatusMessage(jda);
if(state.messageId == 0L || state.channelId != channel.getIdLong())
{
debug(guild, state, "repost_due_to_missing_or_channel", "channel", channel.getIdLong());
repostStatusMessage(guild, channel, state, statusMessage);
}
else
{
final long editingId = state.messageId;
channel.editMessageById(editingId, MessageEditData.fromCreateData(statusMessage))
.queue(m -> {}, t ->
{
debug(guild, state, "edit_failed", t, editingId);
if(state.messageId == editingId && shouldResetMessageId(t))
state.messageId = 0L;
});
}
state.forceUpdate = false;
}
}
}
private void repostStatusMessage(Guild guild, TextChannel channel, StatusState state)
{
MessageCreateData statusMessage = getStatusMessage(guild);
if(statusMessage == null)
return;
repostStatusMessage(guild, channel, state, statusMessage);
}
private void repostStatusMessage(Guild guild, TextChannel channel, StatusState state, MessageCreateData statusMessage)
{
if(state.createInFlight)
return;
state.createInFlight = true;
debug(guild, state, "repost_start", "channel", channel.getIdLong());
deleteOldStatusMessage(guild, state);
channel.sendMessage(statusMessage).queue(m ->
{
state.channelId = channel.getIdLong();
state.messageId = m.getIdLong();
state.createInFlight = false;
debug(guild, state, "repost_success", "message", m.getIdLong());
}, t ->
{
state.createInFlight = false;
debug(guild, state, "repost_failed", t, 0L);
});
}
private void deleteOldStatusMessage(Guild guild, StatusState state)
{
if(state.messageId == 0L)
return;
TextChannel oldChannel = guild.getTextChannelById(state.channelId);
if(oldChannel == null)
return;
oldChannel.deleteMessageById(state.messageId).queue(m -> {}, t -> debug(guild, state, "delete_failed", t, state.messageId));
}
private boolean shouldResetMessageId(Throwable throwable)
{
if(throwable instanceof ErrorResponseException)
{
ErrorResponse response = ((ErrorResponseException)throwable).getErrorResponse();
return response == ErrorResponse.UNKNOWN_MESSAGE || response == ErrorResponse.UNKNOWN_CHANNEL;
}
return false;
}
private void debug(Guild guild, StatusState state, String action, String key, long value)
{
if(LOG.isDebugEnabled())
LOG.debug("guild={} action={} {}={} msgId={} chanId={} pending={} inFlight={} force={}",
guild.getId(), action, key, value, state.messageId, state.channelId, state.pendingRepost, state.createInFlight, state.forceUpdate);
}
private void debug(Guild guild, StatusState state, String action, Throwable throwable, long messageId)
{
if(LOG.isDebugEnabled())
{
String err = throwable == null ? "null" : throwable.getClass().getSimpleName() + ": " + throwable.getMessage();
LOG.debug("guild={} action={} error={} msgId={} chanId={} pending={} inFlight={} force={}",
guild.getId(), action, err, messageId == 0L ? state.messageId : messageId,
state.channelId, state.pendingRepost, state.createInFlight, state.forceUpdate);
}
}
private MessageCreateData getStatusMessage(Guild guild)
{
AudioHandler handler = (AudioHandler)guild.getAudioManager().getSendingHandler();
if(handler == null)
return null;
return handler.getStatusMessage(bot.getJDA());
}
private TextChannel resolveStatusChannel(Guild guild, StatusState state)
{
Long id = resolveStatusChannelId(guild, state);
return id == null ? null : guild.getTextChannelById(id);
}
private Long resolveStatusChannelId(Guild guild, StatusState state)
{
Settings settings = bot.getSettingsManager().getSettings(guild);
if(settings == null)
return null;
if(settings.getTextChannel(guild) != null)
return settings.getTextChannel(guild).getIdLong();
return state.lastCommandChannelId == 0L ? null : state.lastCommandChannelId;
}
private boolean isCommandMessage(Message message, Guild guild)
{
String content = message.getContentRaw();
if(content == null || content.isEmpty())
return false;
Settings settings = bot.getSettingsManager().getSettings(guild);
String prefix = settings.getPrefix() != null ? settings.getPrefix() : bot.getConfig().getPrefix();
String altprefix = bot.getConfig().getAltPrefix();
if(prefix != null && prefix.equalsIgnoreCase("@mention"))
{
long botId = bot.getJDA().getSelfUser().getIdLong();
String mention = "<@" + botId + ">";
String mentionNick = "<@!" + botId + ">";
if(content.startsWith(mention) || content.startsWith(mentionNick))
return true;
}
if(prefix != null && !prefix.equalsIgnoreCase("@mention") && content.startsWith(prefix))
return true;
return altprefix != null && !altprefix.isEmpty() && content.startsWith(altprefix);
}
private static class StatusState
{
private long channelId = 0L;
private long messageId = 0L;
private long lastCommandChannelId = 0L;
private boolean pendingRepost = false;
private boolean forceUpdate = false;
private boolean createInFlight = false;
}
}
@@ -16,6 +16,10 @@
package com.jagrosh.jmusicbot.commands;
import com.jagrosh.jdautilities.command.Command;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
import net.dv8tion.jda.api.Permission;
/**
@@ -24,8 +28,11 @@ import net.dv8tion.jda.api.Permission;
*/
public abstract class AdminCommand extends Command
{
public AdminCommand()
protected final Bot bot;
public AdminCommand(Bot bot)
{
this.bot = bot;
this.category = new Category("Admin", event ->
{
if(event.getAuthor().getId().equals(event.getClient().getOwnerId()))
@@ -36,4 +43,40 @@ public abstract class AdminCommand extends Command
});
this.guildOnly = true;
}
@Override
protected final void execute(CommandEvent event)
{
CommandLogContext.clear();
try
{
doCommand(event);
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
if(entry != null && entry.manual)
return;
String result = entry == null || entry.result == null ? "OK" : entry.result;
JSONObject meta = entry == null ? null : entry.meta;
logCommand(event, result, meta);
}
catch(Exception ex)
{
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
JSONObject meta = entry == null ? null : entry.meta;
if(meta == null)
meta = new JSONObject();
meta.put("error_reason", ex.getClass().getSimpleName());
logCommand(event, "ERROR", meta);
throw ex;
}
}
public abstract void doCommand(CommandEvent event);
protected void logCommand(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -0,0 +1,68 @@
/*
* Copyright 2026
*
* 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.
*/
package com.jagrosh.jmusicbot.commands;
import com.jagrosh.jdautilities.command.Command;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
public abstract class LoggedCommand extends Command
{
protected final Bot bot;
public LoggedCommand(Bot bot)
{
this.bot = bot;
}
@Override
protected final void execute(CommandEvent event)
{
CommandLogContext.clear();
try
{
doCommand(event);
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
if(entry != null && entry.manual)
return;
String result = entry == null || entry.result == null ? "OK" : entry.result;
JSONObject meta = entry == null ? null : entry.meta;
logCommand(event, result, meta);
}
catch(Exception ex)
{
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
JSONObject meta = entry == null ? null : entry.meta;
if(meta == null)
meta = new JSONObject();
meta.put("error_reason", ex.getClass().getSimpleName());
logCommand(event, "ERROR", meta);
throw ex;
}
}
public abstract void doCommand(CommandEvent event);
protected void logCommand(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -20,6 +20,8 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.settings.Settings;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
@@ -45,6 +47,7 @@ public abstract class MusicCommand extends Command
@Override
protected void execute(CommandEvent event)
{
CommandLogContext.clear();
Settings settings = event.getClient().getSettingsFor(event.getGuild());
TextChannel tchannel = settings.getTextChannel(event.getGuild());
if(tchannel!=null && !event.getTextChannel().equals(tchannel))
@@ -54,12 +57,14 @@ public abstract class MusicCommand extends Command
event.getMessage().delete().queue();
} catch(PermissionException ignore){}
event.replyInDm(event.getClient().getError()+" You can only use that command in "+tchannel.getAsMention()+"!");
logCommand(event, "ERROR", new JSONObject().put("error_reason", "wrong_text_channel"));
return;
}
bot.getPlayerManager().setUpHandler(event.getGuild()); // no point constantly checking for this later
if(bePlaying && !((AudioHandler)event.getGuild().getAudioManager().getSendingHandler()).isMusicPlaying(event.getJDA()))
{
event.reply(event.getClient().getError()+" There must be music playing to use that!");
logCommand(event, "ERROR", new JSONObject().put("error_reason", "no_music_playing"));
return;
}
if(beListening)
@@ -71,6 +76,7 @@ public abstract class MusicCommand extends Command
if(!userState.inAudioChannel() || userState.isDeafened() || (current!=null && !userState.getChannel().equals(current)))
{
event.replyError("You must be listening in "+(current==null ? "a voice channel" : current.getAsMention())+" to use that!");
logCommand(event, "ERROR", new JSONObject().put("error_reason", "not_listening"));
return;
}
@@ -78,6 +84,7 @@ public abstract class MusicCommand extends Command
if(afkChannel != null && afkChannel.equals(userState.getChannel()))
{
event.replyError("You cannot use that command in an AFK channel!");
logCommand(event, "ERROR", new JSONObject().put("error_reason", "afk_channel"));
return;
}
@@ -90,13 +97,41 @@ public abstract class MusicCommand extends Command
catch(PermissionException ex)
{
event.reply(event.getClient().getError()+" I am unable to connect to "+userState.getChannel().getAsMention()+"!");
logCommand(event, "ERROR", new JSONObject().put("error_reason", "cannot_connect"));
return;
}
}
}
doCommand(event);
try
{
doCommand(event);
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
if(entry != null && entry.manual)
return;
String result = entry == null || entry.result == null ? "OK" : entry.result;
JSONObject meta = entry == null ? null : entry.meta;
logCommand(event, result, meta);
}
catch(Exception ex)
{
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
JSONObject meta = entry == null ? null : entry.meta;
if(meta == null)
meta = new JSONObject();
meta.put("error_reason", ex.getClass().getSimpleName());
logCommand(event, "ERROR", meta);
throw ex;
}
}
public abstract void doCommand(CommandEvent event);
protected void logCommand(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -16,6 +16,10 @@
package com.jagrosh.jmusicbot.commands;
import com.jagrosh.jdautilities.command.Command;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
/**
*
@@ -23,9 +27,48 @@ import com.jagrosh.jdautilities.command.Command;
*/
public abstract class OwnerCommand extends Command
{
public OwnerCommand()
protected final Bot bot;
public OwnerCommand(Bot bot)
{
this.bot = bot;
this.category = new Category("Owner");
this.ownerCommand = true;
}
@Override
protected final void execute(CommandEvent event)
{
CommandLogContext.clear();
try
{
doCommand(event);
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
if(entry != null && entry.manual)
return;
String result = entry == null || entry.result == null ? "OK" : entry.result;
JSONObject meta = entry == null ? null : entry.meta;
logCommand(event, result, meta);
}
catch(Exception ex)
{
CommandLogContext.CommandLogEntry entry = CommandLogContext.consume();
JSONObject meta = entry == null ? null : entry.meta;
if(meta == null)
meta = new JSONObject();
meta.put("error_reason", ex.getClass().getSimpleName());
logCommand(event, "ERROR", meta);
throw ex;
}
}
public abstract void doCommand(CommandEvent event);
protected void logCommand(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -18,7 +18,9 @@ package com.jagrosh.jmusicbot.commands.admin;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.AdminCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import org.json.JSONObject;
/**
*
@@ -28,6 +30,7 @@ public class PrefixCmd extends AdminCommand
{
public PrefixCmd(Bot bot)
{
super(bot);
this.name = "prefix";
this.help = "sets a server-specific prefix";
this.arguments = "<prefix|NONE>";
@@ -35,24 +38,30 @@ public class PrefixCmd extends AdminCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(event.getArgs().isEmpty())
{
event.replyError("Please include a prefix or NONE");
CommandLogContext.setError("missing_prefix");
return;
}
Settings s = event.getClient().getSettingsFor(event.getGuild());
String before = s.getPrefix();
if(event.getArgs().equalsIgnoreCase("none"))
{
s.setPrefix(null);
event.replySuccess("Prefix cleared.");
JSONObject meta = new JSONObject().put("before", before).put("after", JSONObject.NULL);
CommandLogContext.setMeta(meta);
}
else
{
s.setPrefix(event.getArgs());
event.replySuccess("Custom prefix set to `" + event.getArgs() + "` on *" + event.getGuild().getName() + "*");
JSONObject meta = new JSONObject().put("before", before).put("after", event.getArgs());
CommandLogContext.setMeta(meta);
}
}
}
@@ -19,8 +19,10 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.AdminCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.QueueType;
import com.jagrosh.jmusicbot.settings.Settings;
import org.json.JSONObject;
/**
*
@@ -30,7 +32,7 @@ public class QueueTypeCmd extends AdminCommand
{
public QueueTypeCmd(Bot bot)
{
super();
super(bot);
this.name = "queuetype";
this.help = "changes the queue type";
this.arguments = "[" + String.join("|", QueueType.getNames()) + "]";
@@ -38,16 +40,18 @@ public class QueueTypeCmd extends AdminCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
String args = event.getArgs();
QueueType value;
Settings settings = event.getClient().getSettingsFor(event.getGuild());
QueueType before = settings.getQueueType();
if (args.isEmpty())
{
QueueType currentType = settings.getQueueType();
event.reply(currentType.getEmoji() + " Current queue type is: `" + currentType.getUserFriendlyName() + "`.");
CommandLogContext.setMeta(new JSONObject().put("current", currentType.name()));
return;
}
@@ -58,6 +62,7 @@ public class QueueTypeCmd extends AdminCommand
catch (IllegalArgumentException e)
{
event.replyError("Invalid queue type. Valid types are: [" + String.join("|", QueueType.getNames()) + "]");
CommandLogContext.setError("invalid_queue_type");
return;
}
@@ -71,5 +76,6 @@ public class QueueTypeCmd extends AdminCommand
}
event.reply(value.getEmoji() + " Queue type was set to `" + value.getUserFriendlyName() + "`.");
CommandLogContext.setMeta(new JSONObject().put("before", before.name()).put("after", value.name()));
}
}
@@ -20,9 +20,11 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.AdminCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import net.dv8tion.jda.api.entities.Role;
import org.json.JSONObject;
/**
*
@@ -32,6 +34,7 @@ public class SetdjCmd extends AdminCommand
{
public SetdjCmd(Bot bot)
{
super(bot);
this.name = "setdj";
this.help = "sets the DJ role for certain music commands";
this.arguments = "<rolename|NONE>";
@@ -39,30 +42,40 @@ public class SetdjCmd extends AdminCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(event.getArgs().isEmpty())
{
event.reply(event.getClient().getError()+" Please include a role name or NONE");
CommandLogContext.setError("missing_role");
return;
}
Settings s = event.getClient().getSettingsFor(event.getGuild());
String before = s.getRole(event.getGuild()) == null ? null : s.getRole(event.getGuild()).getId();
if(event.getArgs().equalsIgnoreCase("none"))
{
s.setDJRole(null);
event.reply(event.getClient().getSuccess()+" DJ role cleared; Only Admins can use the DJ commands.");
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", JSONObject.NULL));
}
else
{
List<Role> list = FinderUtil.findRoles(event.getArgs(), event.getGuild());
if(list.isEmpty())
{
event.reply(event.getClient().getWarning()+" No Roles found matching \""+event.getArgs()+"\"");
CommandLogContext.setError("role_not_found");
}
else if (list.size()>1)
{
event.reply(event.getClient().getWarning()+FormatUtil.listOfRoles(list, event.getArgs()));
CommandLogContext.setError("role_ambiguous");
}
else
{
s.setDJRole(list.get(0));
event.reply(event.getClient().getSuccess()+" DJ commands can now be used by users with the **"+list.get(0).getName()+"** role.");
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", list.get(0).getId()));
}
}
}
@@ -20,9 +20,11 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.AdminCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import org.json.JSONObject;
/**
*
@@ -32,6 +34,7 @@ public class SettcCmd extends AdminCommand
{
public SettcCmd(Bot bot)
{
super(bot);
this.name = "settc";
this.help = "sets the text channel for music commands";
this.arguments = "<channel|NONE>";
@@ -39,31 +42,41 @@ public class SettcCmd extends AdminCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(event.getArgs().isEmpty())
{
event.reply(event.getClient().getError()+" Please include a text channel or NONE");
CommandLogContext.setError("missing_text_channel");
return;
}
Settings s = event.getClient().getSettingsFor(event.getGuild());
String before = s.getTextChannel(event.getGuild()) == null ? null : s.getTextChannel(event.getGuild()).getId();
if(event.getArgs().equalsIgnoreCase("none"))
{
s.setTextChannel(null);
event.reply(event.getClient().getSuccess()+" Music commands can now be used in any channel");
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", JSONObject.NULL));
}
else
{
@SuppressWarnings({"unchecked", "rawtypes"})
List<TextChannel> list = (List) FinderUtil.findTextChannels(event.getArgs(), event.getGuild());
if(list.isEmpty())
{
event.reply(event.getClient().getWarning()+" No Text Channels found matching \""+event.getArgs()+"\"");
CommandLogContext.setError("text_channel_not_found");
}
else if (list.size()>1)
{
event.reply(event.getClient().getWarning()+FormatUtil.listOfTChannels(list, event.getArgs()));
CommandLogContext.setError("text_channel_ambiguous");
}
else
{
s.setTextChannel(list.get(0));
event.reply(event.getClient().getSuccess()+" Music commands can now only be used in <#"+list.get(0).getId()+">");
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", list.get(0).getId()));
}
}
}
@@ -20,9 +20,11 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.AdminCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import org.json.JSONObject;
/**
*
@@ -32,6 +34,7 @@ public class SetvcCmd extends AdminCommand
{
public SetvcCmd(Bot bot)
{
super(bot);
this.name = "setvc";
this.help = "sets the voice channel for playing music";
this.arguments = "<channel|NONE>";
@@ -39,31 +42,41 @@ public class SetvcCmd extends AdminCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(event.getArgs().isEmpty())
{
event.reply(event.getClient().getError()+" Please include a voice channel or NONE");
CommandLogContext.setError("missing_voice_channel");
return;
}
Settings s = event.getClient().getSettingsFor(event.getGuild());
String before = s.getVoiceChannel(event.getGuild()) == null ? null : s.getVoiceChannel(event.getGuild()).getId();
if(event.getArgs().equalsIgnoreCase("none"))
{
s.setVoiceChannel(null);
event.reply(event.getClient().getSuccess()+" Music can now be played in any channel");
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", JSONObject.NULL));
}
else
{
@SuppressWarnings({"unchecked", "rawtypes"})
List<VoiceChannel> list = (List) FinderUtil.findVoiceChannels(event.getArgs(), event.getGuild());
if(list.isEmpty())
{
event.reply(event.getClient().getWarning()+" No Voice Channels found matching \""+event.getArgs()+"\"");
CommandLogContext.setError("voice_channel_not_found");
}
else if (list.size()>1)
{
event.reply(event.getClient().getWarning()+FormatUtil.listOfVChannels(list, event.getArgs()));
CommandLogContext.setError("voice_channel_ambiguous");
}
else
{
s.setVoiceChannel(list.get(0));
event.reply(event.getClient().getSuccess()+" Music can now only be played in "+list.get(0).getAsMention());
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", list.get(0).getId()));
}
}
}
@@ -18,7 +18,9 @@ package com.jagrosh.jmusicbot.commands.admin;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.AdminCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import org.json.JSONObject;
/**
*
@@ -28,6 +30,7 @@ public class SkipratioCmd extends AdminCommand
{
public SkipratioCmd(Bot bot)
{
super(bot);
this.name = "setskip";
this.help = "sets a server-specific skip percentage";
this.arguments = "<0 - 100>";
@@ -35,7 +38,7 @@ public class SkipratioCmd extends AdminCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
try
{
@@ -43,15 +46,21 @@ public class SkipratioCmd extends AdminCommand
if( val < 0 || val > 100)
{
event.replyError("The provided value must be between 0 and 100!");
CommandLogContext.setError("invalid_skip_ratio");
return;
}
Settings s = event.getClient().getSettingsFor(event.getGuild());
double before = s.getSkipRatio();
s.setSkipRatio(val / 100.0);
event.replySuccess("Skip percentage has been set to `" + val + "%` of listeners on *" + event.getGuild().getName() + "*");
CommandLogContext.setMeta(new JSONObject()
.put("before", before)
.put("after", s.getSkipRatio()));
}
catch(NumberFormatException ex)
{
event.replyError("Please include an integer between 0 and 100 (default is 55). This number is the percentage of listening users that must vote to skip a song.");
CommandLogContext.setError("invalid_skip_ratio");
}
}
}
@@ -21,10 +21,12 @@ import com.jagrosh.jdautilities.menu.OrderedMenu;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.User;
import org.json.JSONObject;
import java.util.List;
import java.util.concurrent.TimeUnit;
@@ -53,6 +55,7 @@ public class ForceRemoveCmd extends DJCommand
if (event.getArgs().isEmpty())
{
event.replyError("You need to mention a user!");
CommandLogContext.setError("missing_user");
return;
}
@@ -60,6 +63,7 @@ public class ForceRemoveCmd extends DJCommand
if (handler.getQueue().isEmpty())
{
event.replyError("There is nothing in the queue!");
CommandLogContext.setError("empty_queue");
return;
}
@@ -70,6 +74,7 @@ public class ForceRemoveCmd extends DJCommand
if(found.isEmpty())
{
event.replyError("Unable to find the user!");
CommandLogContext.setError("user_not_found");
return;
}
else if(found.size()>1)
@@ -107,14 +112,26 @@ public class ForceRemoveCmd extends DJCommand
private void removeAllEntries(User target, CommandEvent event)
{
int count = ((AudioHandler) event.getGuild().getAudioManager().getSendingHandler()).getQueue().removeAll(target.getIdLong());
AudioHandler handler = (AudioHandler) event.getGuild().getAudioManager().getSendingHandler();
int sizeBefore = handler.getQueue().size();
int count = handler.getQueue().removeAll(target.getIdLong());
if (count == 0)
{
event.replyWarning("**"+target.getName()+"** doesn't have any songs in the queue!");
CommandLogContext.setError("no_entries_for_user");
}
else
{
event.replySuccess("Successfully removed `"+count+"` entries from "+FormatUtil.formatUsername(target)+".");
}
JSONObject meta = new JSONObject()
.put("target_user_id", target.getIdLong())
.put("removed_count", count)
.put("queue_size_before", sizeBefore)
.put("queue_size_after", handler.getQueue().size());
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), null, "REMOVE_ALL",
"FORCE", null, null, null, meta.toString());
}
}
@@ -20,7 +20,9 @@ import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.RequestMetadata;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import org.json.JSONObject;
/**
*
@@ -44,6 +46,13 @@ public class ForceskipCmd extends DJCommand
RequestMetadata rm = handler.getRequestMetadata();
event.reply(event.getClient().getSuccess()+" Skipped **"+handler.getPlayer().getPlayingTrack().getInfo().title
+"** "+(rm.getOwner() == 0L ? "(autoplay)" : "(requested by **" + FormatUtil.formatUsername(rm.user) + "**)"));
if(bot.getDataLogService() != null)
{
JSONObject meta = new JSONObject().put("reason", "FORCE");
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"SKIP_FORCE", null, null, null, null, meta.toString());
CommandLogContext.setMeta(meta);
}
handler.getPlayer().stopTrack();
}
}
@@ -6,7 +6,9 @@ import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.QueuedTrack;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.queue.AbstractQueue;
import org.json.JSONObject;
/**
* Command that provides users the ability to move a track in the playlist.
@@ -34,6 +36,7 @@ public class MoveTrackCmd extends DJCommand
if(parts.length < 2)
{
event.replyError("Please include two valid indexes.");
CommandLogContext.setError("missing_indexes");
return;
}
@@ -46,12 +49,14 @@ public class MoveTrackCmd extends DJCommand
catch (NumberFormatException e)
{
event.replyError("Please provide two valid indexes.");
CommandLogContext.setError("invalid_indexes");
return;
}
if (from == to)
{
event.replyError("Can't move a track to the same position.");
CommandLogContext.setError("same_position");
return;
}
@@ -62,24 +67,36 @@ public class MoveTrackCmd extends DJCommand
{
String reply = String.format("`%d` is not a valid position in the queue!", from);
event.replyError(reply);
CommandLogContext.setError("invalid_from_position");
return;
}
if (isUnavailablePosition(queue, to))
{
String reply = String.format("`%d` is not a valid position in the queue!", to);
event.replyError(reply);
CommandLogContext.setError("invalid_to_position");
return;
}
// Move the track
int sizeBefore = queue.size();
QueuedTrack track = queue.moveItem(from - 1, to - 1);
String trackTitle = track.getTrack().getInfo().title;
String reply = String.format("Moved **%s** from position `%d` to `%d`.", trackTitle, from, to);
event.replySuccess(reply);
JSONObject meta = new JSONObject()
.put("position_from", from)
.put("position_to", to)
.put("queue_size_before", sizeBefore)
.put("queue_size_after", queue.size());
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), track.getTrack(),
"MOVE", null, to, null, null, meta.toString());
}
private static boolean isUnavailablePosition(AbstractQueue<QueuedTrack> queue, int position)
{
return (position < 1 || position > queue.size());
}
}
}
@@ -19,6 +19,8 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
/**
*
@@ -42,9 +44,16 @@ public class PauseCmd extends DJCommand
if(handler.getPlayer().isPaused())
{
event.replyWarning("The player is already paused! Use `"+event.getClient().getPrefix()+"play` to unpause!");
CommandLogContext.setError("already_paused");
return;
}
handler.getPlayer().setPaused(true);
event.replySuccess("Paused **"+handler.getPlayer().getPlayingTrack().getInfo().title+"**. Type `"+event.getClient().getPrefix()+"play` to unpause!");
JSONObject meta = new JSONObject()
.put("position_ms", handler.getPlayer().getPlayingTrack().getPosition());
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"PAUSE", null, null, null, null, meta.toString());
}
}
@@ -21,6 +21,7 @@ import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.QueuedTrack;
import com.jagrosh.jmusicbot.audio.RequestMetadata;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import com.jagrosh.jmusicbot.utils.TimeUtil;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
@@ -28,6 +29,7 @@ import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import net.dv8tion.jda.api.entities.Message;
import org.json.JSONObject;
/**
*
@@ -52,9 +54,11 @@ public class PlaynextCmd extends DJCommand
@Override
public void doCommand(CommandEvent event)
{
CommandLogContext.setManualLogging();
if(event.getArgs().isEmpty() && event.getMessage().getAttachments().isEmpty())
{
event.replyWarning("Please include a song title or URL!");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "missing_query"));
return;
}
String args = event.getArgs().startsWith("<") && event.getArgs().endsWith(">")
@@ -82,13 +86,18 @@ public class PlaynextCmd extends DJCommand
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" This track (**"+track.getInfo().title+"**) is longer than the allowed maximum: `"
+ TimeUtil.formatTime(track.getDuration())+"` > `"+ TimeUtil.formatTime(bot.getConfig().getMaxSeconds()*1000)+"`")).queue();
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "track_too_long"));
return;
}
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
int pos = handler.addTrackToFront(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1;
RequestMetadata rm = RequestMetadata.fromResultHandler(track, event, "PLAYNEXT", null, event.getArgs());
int pos = handler.addTrackToFront(new QueuedTrack(track, rm))+1;
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueAdd(event.getGuild(), event.getAuthor(), track, "PLAYNEXT", pos, event.getArgs(), null);
String addMsg = FormatUtil.filter(event.getClient().getSuccess()+" Added **"+track.getInfo().title
+"** (`"+ TimeUtil.formatTime(track.getDuration())+"`) "+(pos==0?"to begin playing":" to the queue at position "+pos));
m.editMessage(addMsg).queue();
logCommandEvent(event, "OK", new JSONObject().put("queue_position", pos));
}
@Override
@@ -114,7 +123,10 @@ public class PlaynextCmd extends DJCommand
public void noMatches()
{
if(ytsearch)
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" No results found for `"+event.getArgs()+"`.")).queue();
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "no_matches"));
}
else
bot.getPlayerManager().loadItemOrdered(event.getGuild(), "ytsearch:"+event.getArgs(), new ResultHandler(m,event,true));
}
@@ -126,6 +138,15 @@ public class PlaynextCmd extends DJCommand
m.editMessage(event.getClient().getError()+" Error loading: "+throwable.getMessage()).queue();
else
m.editMessage(event.getClient().getError()+" Error loading track.").queue();
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", throwable.getMessage()));
}
}
private void logCommandEvent(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -18,8 +18,10 @@ package com.jagrosh.jmusicbot.commands.dj;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.RepeatMode;
import com.jagrosh.jmusicbot.settings.Settings;
import org.json.JSONObject;
/**
*
@@ -37,13 +39,13 @@ public class RepeatCmd extends DJCommand
this.guildOnly = true;
}
// override musiccommand's execute because we don't actually care where this is used
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
String args = event.getArgs();
RepeatMode value;
Settings settings = event.getClient().getSettingsFor(event.getGuild());
RepeatMode before = settings.getRepeatMode();
if(args.isEmpty())
{
if(settings.getRepeatMode() == RepeatMode.OFF)
@@ -66,12 +68,11 @@ public class RepeatCmd extends DJCommand
else
{
event.replyError("Valid options are `off`, `all` or `single` (or leave empty to toggle between `off` and `all`)");
CommandLogContext.setError("invalid_repeat_mode");
return;
}
settings.setRepeatMode(value);
event.replySuccess("Repeat mode is now `"+value.getUserFriendlyName()+"`");
CommandLogContext.setMeta(new JSONObject().put("before", before.name()).put("after", value.name()));
}
@Override
public void doCommand(CommandEvent event) { /* Intentionally Empty */ }
}
@@ -19,6 +19,8 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
/**
*
@@ -47,16 +49,27 @@ public class SkiptoCmd extends DJCommand
catch(NumberFormatException e)
{
event.reply(event.getClient().getError()+" `"+event.getArgs()+"` is not a valid integer!");
CommandLogContext.setError("invalid_index");
return;
}
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
if(index<1 || index>handler.getQueue().size())
{
event.reply(event.getClient().getError()+" Position must be a valid integer between 1 and "+handler.getQueue().size()+"!");
CommandLogContext.setError("index_out_of_range");
return;
}
int sizeBefore = handler.getQueue().size();
handler.getQueue().skip(index-1);
event.reply(event.getClient().getSuccess()+" Skipped to **"+handler.getQueue().get(0).getTrack().getInfo().title+"**");
JSONObject meta = new JSONObject()
.put("position_to", index)
.put("queue_size_before", sizeBefore)
.put("queue_size_after", handler.getQueue().size());
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"SKIP_SKIPTO", null, index, null, null, meta.toString());
handler.getPlayer().stopTrack();
}
}
@@ -19,6 +19,8 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
/**
*
@@ -39,6 +41,16 @@ public class StopCmd extends DJCommand
public void doCommand(CommandEvent event)
{
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
int sizeBefore = handler.getQueue().size();
if(handler.getPlayer().getPlayingTrack() != null && bot.getDataLogService() != null)
{
JSONObject meta = new JSONObject()
.put("queue_size_before", sizeBefore)
.put("queue_size_after", 0);
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"STOP", null, null, null, null, meta.toString());
CommandLogContext.setMeta(meta);
}
handler.stopAndClear();
event.getGuild().getAudioManager().closeAudioConnection();
event.reply(event.getClient().getSuccess()+" The player has stopped and the queue has been cleared.");
@@ -19,8 +19,10 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import org.json.JSONObject;
/**
*
@@ -46,6 +48,7 @@ public class VolumeCmd extends DJCommand
if(event.getArgs().isEmpty())
{
event.reply(FormatUtil.volumeIcon(volume)+" Current volume is `"+volume+"`");
CommandLogContext.setMeta(new JSONObject().put("current", volume));
}
else
{
@@ -56,12 +59,16 @@ public class VolumeCmd extends DJCommand
nvolume = -1;
}
if(nvolume<0 || nvolume>150)
{
event.reply(event.getClient().getError()+" Volume must be a valid integer between 0 and 150!");
CommandLogContext.setError("invalid_volume");
}
else
{
handler.getPlayer().setVolume(nvolume);
settings.setVolume(nvolume);
event.reply(FormatUtil.volumeIcon(nvolume)+" Volume changed from `"+volume+"` to `"+nvolume+"`");
CommandLogContext.setMeta(new JSONObject().put("before", volume).put("after", nvolume));
}
}
}
@@ -0,0 +1,201 @@
/*
* Copyright 2026
*
* 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.
*/
package com.jagrosh.jmusicbot.commands.general;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jdautilities.examples.command.AboutCommand;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.JMusicBot;
import com.jagrosh.jmusicbot.utils.OtherUtil;
import java.awt.Color;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDAInfo;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.ApplicationInfo;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.User;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import org.json.JSONObject;
public class LoggedAboutCmd extends AboutCommand
{
private final Bot bot;
private final Color color;
private final String description;
private final String[] features;
private final Permission[] perms;
private boolean isAuthor = true;
private String replacementCharacter = "+";
private String oauthLink;
public LoggedAboutCmd(Bot bot, Color color, String description, String[] features, Permission[] perms)
{
super(color, description, features, perms);
this.bot = bot;
this.color = color;
this.description = description;
this.features = features == null ? new String[0] : features.clone();
this.perms = perms == null ? new Permission[0] : perms.clone();
}
@Override
public void setIsAuthor(boolean isAuthor)
{
super.setIsAuthor(isAuthor);
this.isAuthor = isAuthor;
}
@Override
public void setReplacementCharacter(String replacementCharacter)
{
super.setReplacementCharacter(replacementCharacter);
this.replacementCharacter = replacementCharacter;
}
@Override
protected void execute(CommandEvent event)
{
if(oauthLink == null)
oauthLink = resolveOauthLink(event);
EmbedBuilder builder = new EmbedBuilder()
.setColor(event.isFromType(ChannelType.TEXT)
? event.getGuild().getSelfMember().getColor()
: color)
.setAuthor("All about " + event.getSelfUser().getName() + "!",
null,
event.getSelfUser().getAvatarUrl())
.setDescription(buildDescription(event));
addStats(builder, event);
builder.setFooter("Last restart", null)
.setTimestamp(event.getClient().getStartTime());
event.reply(builder.build());
if(bot.getDataLogService() != null && event.getGuild() != null)
{
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), "OK", new JSONObject().put("about", true).toString());
}
}
private String resolveOauthLink(CommandEvent event)
{
try
{
ApplicationInfo info = event.getJDA().retrieveApplicationInfo().complete();
return info.isBotPublic() ? info.getInviteUrl(0, perms) : "";
}
catch(Exception ex)
{
JMusicBot.LOG.error("Could not generate invite link", ex);
return "";
}
}
private String buildDescription(CommandEvent event)
{
String owner = getOwnerDisplay(event);
String links = getLinksSection(event);
StringBuilder builder = new StringBuilder()
.append("Hello! I am **").append(event.getSelfUser().getName()).append("**, ")
.append(description)
.append("\nI ")
.append(isAuthor ? "was written in Java" : "am owned")
.append(" by **").append(owner).append("**")
.append(" using Chew's [Commands Extension](https://github.com/Chew/JDA-Chewtils) (")
.append(OtherUtil.getCommandLibraryVersion())
.append(") and the [JDA library](https://github.com/DV8FromTheWorld/JDA) (")
.append(JDAInfo.VERSION)
.append(")\nType `")
.append(event.getClient().getTextualPrefix())
.append(event.getClient().getHelpWord())
.append("` to see my commands!");
if(!links.isEmpty())
builder.append(links);
builder.append("\n\nSome of my features include: ```css");
for(String feature : features)
{
builder.append("\n")
.append(event.getClient().getSuccess().startsWith("<")
? replacementCharacter
: event.getClient().getSuccess())
.append(" ")
.append(feature);
}
builder.append("```");
return builder.toString();
}
private String getOwnerDisplay(CommandEvent event)
{
User owner = event.getJDA().getUserById(event.getClient().getOwnerId());
return owner == null ? "<@" + event.getClient().getOwnerId() + ">" : owner.getName();
}
private String getLinksSection(CommandEvent event)
{
String serverInvite = event.getClient().getServerInvite();
boolean hasServerInvite = serverInvite != null && !serverInvite.isEmpty();
boolean hasOauthLink = oauthLink != null && !oauthLink.isEmpty();
if(!hasServerInvite && !hasOauthLink)
return "";
StringBuilder builder = new StringBuilder("\n");
if(hasServerInvite)
builder.append("Join my server [`here`](").append(serverInvite).append(")");
else if(hasOauthLink)
builder.append("Please");
if(hasOauthLink)
{
if(hasServerInvite)
builder.append(", or");
builder.append(" [`invite`](").append(oauthLink).append(") me to your server");
}
builder.append("!");
return builder.toString();
}
private void addStats(EmbedBuilder builder, CommandEvent event)
{
JDA jda = event.getJDA();
if(jda.getShardInfo() == JDA.ShardInfo.SINGLE)
{
builder.addField("Stats", jda.getGuilds().size() + " servers\n1 shard", true)
.addField("Users",
jda.getUsers().size() + " unique\n"
+ jda.getGuilds().stream().mapToInt(Guild::getMemberCount).sum() + " total",
true)
.addField("Channels",
jda.getTextChannels().size() + " Text\n" + jda.getVoiceChannels().size() + " Voice",
true);
return;
}
builder.addField("Stats",
event.getClient().getTotalGuilds() + " Servers\nShard "
+ (jda.getShardInfo().getShardId() + 1) + "/" + jda.getShardInfo().getShardTotal(),
true)
.addField("This shard", jda.getUsers().size() + " Users\n" + jda.getGuilds().size() + " Servers", true)
.addField("",
jda.getTextChannels().size() + " Text Channels\n" + jda.getVoiceChannels().size() + " Voice Channels",
true);
}
}
@@ -0,0 +1,43 @@
/*
* Copyright 2026
*
* 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.
*/
package com.jagrosh.jmusicbot.commands.general;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jdautilities.examples.command.PingCommand;
import com.jagrosh.jmusicbot.Bot;
import org.json.JSONObject;
public class LoggedPingCmd extends PingCommand
{
private final Bot bot;
public LoggedPingCmd(Bot bot)
{
super();
this.bot = bot;
}
@Override
protected void execute(CommandEvent event)
{
super.execute(event);
if(bot.getDataLogService() != null && event.getGuild() != null)
{
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), "OK", new JSONObject().put("ping", true).toString());
}
}
}
@@ -15,9 +15,10 @@
*/
package com.jagrosh.jmusicbot.commands.general;
import com.jagrosh.jdautilities.command.Command;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.LoggedCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.QueueType;
import com.jagrosh.jmusicbot.settings.RepeatMode;
import com.jagrosh.jmusicbot.settings.Settings;
@@ -27,17 +28,19 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateBuilder;
import net.dv8tion.jda.api.entities.Role;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
import org.json.JSONObject;
/**
*
* @author John Grosh <john.a.grosh@gmail.com>
*/
public class SettingsCmd extends Command
public class SettingsCmd extends LoggedCommand
{
private final static String EMOJI = "\uD83C\uDFA7"; // 🎧
public SettingsCmd(Bot bot)
{
super(bot);
this.name = "settings";
this.help = "shows the bots settings";
this.aliases = bot.getConfig().getAliases(this.name);
@@ -45,15 +48,27 @@ public class SettingsCmd extends Command
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
Settings s = event.getClient().getSettingsFor(event.getGuild());
JSONObject meta = new JSONObject()
.put("repeat_mode", s.getRepeatMode().name())
.put("queue_type", s.getQueueType().name())
.put("has_default_playlist", s.getDefaultPlaylist() != null)
.put("has_custom_prefix", s.getPrefix() != null)
.put("has_text_channel", s.getTextChannel(event.getGuild()) != null)
.put("has_voice_channel", s.getVoiceChannel(event.getGuild()) != null)
.put("has_dj_role", s.getRole(event.getGuild()) != null)
.put("stats_report_frequency", s.getStatsReportFrequency().name())
.put("has_stats_report_channel", s.getStatsReportChannel(event.getGuild()) != null);
CommandLogContext.setMeta(meta);
MessageCreateBuilder builder = new MessageCreateBuilder()
.addContent(EMOJI + " **")
.addContent(FormatUtil.filter(event.getSelfUser().getName()))
.addContent("** settings:");
TextChannel tchan = s.getTextChannel(event.getGuild());
VoiceChannel vchan = s.getVoiceChannel(event.getGuild());
TextChannel statsChannel = s.getStatsReportChannel(event.getGuild());
Role role = s.getRole(event.getGuild());
EmbedBuilder ebuilder = new EmbedBuilder()
.setColor(event.getSelfMember().getColor())
@@ -68,6 +83,8 @@ public class SettingsCmd extends Command
? s.getQueueType().getUserFriendlyName()
: "**"+s.getQueueType().getUserFriendlyName()+"**")
+ "\nDefault Playlist: " + (s.getDefaultPlaylist() == null ? "None" : "**" + s.getDefaultPlaylist() + "**")
+ "\nStats Reports: " + (s.getStatsReportFrequency().name())
+ (statsChannel == null ? "" : " in " + statsChannel.getAsMention())
)
.setFooter(event.getJDA().getGuilds().size() + " servers | "
+ event.getJDA().getGuilds().stream().filter(g -> g.getSelfMember().getVoiceState().inAudioChannel()).count()
@@ -0,0 +1,615 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.commands.general;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jdautilities.commons.utils.FinderUtil;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.LoggedCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.datalog.StatsFilters;
import com.jagrosh.jmusicbot.datalog.StatsAccess;
import com.jagrosh.jmusicbot.datalog.StatsMessageFormatter;
import com.jagrosh.jmusicbot.datalog.StatsSummary;
import com.jagrosh.jmusicbot.datalog.StatsTimeRange;
import com.jagrosh.jmusicbot.datalog.StatsRow;
import com.jagrosh.jmusicbot.settings.Settings;
import com.jagrosh.jmusicbot.settings.StatsReportFrequency;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import org.json.JSONObject;
public class StatsCmd extends LoggedCommand
{
public StatsCmd(Bot bot)
{
super(bot);
this.name = "stats";
this.help = "shows playback statistics";
this.arguments = "<server|me|user|tracks|listened|track|skips|sources|reports>";
this.guildOnly = true;
this.aliases = bot.getConfig().getAliases(this.name);
this.botPermissions = new Permission[]{Permission.MESSAGE_EMBED_LINKS};
}
@Override
public void doCommand(CommandEvent event)
{
if(bot.getDataLogService() == null)
{
event.replyWarning("Playback analytics are not enabled.");
CommandLogContext.setError("analytics_disabled");
return;
}
List<String> tokens = new ArrayList<>(Arrays.asList(event.getArgs().trim().split("\\s+")));
if(tokens.size() == 1 && tokens.get(0).isEmpty())
tokens.clear();
String subcommand = tokens.isEmpty() ? "server" : tokens.remove(0).toLowerCase();
switch(subcommand)
{
case "server":
sendSummary(event, "Server stats", parseOptions(tokens), null, Section.ALL);
break;
case "me":
sendSummary(event, "Your stats", parseOptions(tokens), event.getAuthor().getIdLong(), Section.ALL);
break;
case "user":
handleUserStats(event, tokens);
break;
case "track":
handleTrackStats(event, parseOptions(tokens));
break;
case "tracks":
case "played":
handleTrackListStats(event, "Top played tracks", parseOptions(tokens), false);
break;
case "listened":
case "listens":
handleTrackListStats(event, "Top listened tracks", parseOptions(tokens), true);
break;
case "skips":
sendSummary(event, "Skip stats", parseOptions(tokens), null, Section.SKIPS);
break;
case "sources":
sendSummary(event, "Source stats", parseOptions(tokens), null, Section.SOURCES);
break;
case "reports":
handleReports(event, tokens);
break;
case "help":
sendUsage(event);
break;
default:
tokens.add(0, subcommand);
handleUserStats(event, tokens);
}
}
private void handleUserStats(CommandEvent event, List<String> tokens)
{
Options options = parseOptions(tokens);
String targetText = String.join(" ", options.remaining).trim();
if(targetText.isEmpty())
{
event.replyError("Please include a member.");
CommandLogContext.setError("missing_member");
return;
}
Long targetId = parseUserId(targetText);
if(targetId != null)
{
Member member = event.getGuild().getMemberById(targetId);
if(!StatsAccess.canViewUserStats(event.getAuthor().getIdLong(), targetId, canManageServer(event.getMember())))
{
event.replyError("You can only view another member's detailed stats if you can manage this server.");
CommandLogContext.setError("missing_manage_server");
return;
}
sendSummary(event, member == null ? "Stats for user " + targetId : "Stats for " + member.getEffectiveName(), options, targetId, Section.ALL);
return;
}
List<Member> members = FinderUtil.findMembers(targetText, event.getGuild());
if(members.isEmpty())
members = findMembersByPartialName(targetText, event.getGuild().getMembers());
if(members.isEmpty())
{
List<StatsRow> profileMatches = bot.getDataLogService().findUsersByProfile(event.getGuild().getIdLong(), targetText, 6);
if(profileMatches.size() == 1)
{
StatsRow match = profileMatches.get(0);
if(!StatsAccess.canViewUserStats(event.getAuthor().getIdLong(), match.getId(), canManageServer(event.getMember())))
{
event.replyError("You can only view another member's detailed stats if you can manage this server.");
CommandLogContext.setError("missing_manage_server");
return;
}
sendSummary(event, "Stats for " + match.getLabel(), options, match.getId(), Section.ALL);
return;
}
if(profileMatches.size() > 1)
{
event.replyWarning("Multiple analytics users matched `" + targetText + "`: " + formatProfileMatches(profileMatches) + ". Please be more specific or use a mention.");
CommandLogContext.setError("member_ambiguous");
return;
}
event.replyWarning("No member found matching `" + targetText + "`. Try a mention like `@user` or the Discord user ID.");
CommandLogContext.setError("member_not_found");
return;
}
if(members.size() > 1)
{
event.replyWarning("Multiple members matched `" + targetText + "`: " + formatMemberMatches(members) + ". Please be more specific or use a mention.");
CommandLogContext.setError("member_ambiguous");
return;
}
Member target = members.get(0);
if(!StatsAccess.canViewUserStats(event.getAuthor().getIdLong(), target.getIdLong(), canManageServer(event.getMember())))
{
event.replyError("You can only view another member's detailed stats if you can manage this server.");
CommandLogContext.setError("missing_manage_server");
return;
}
sendSummary(event, "Stats for " + target.getEffectiveName(), options, target.getIdLong(), Section.ALL);
}
private void sendUsage(CommandEvent event)
{
event.reply("Use `stats server [range] [source:youtube]`, `stats me [range]`, `stats user <@user|user id|name> [range]`, `stats tracks [range] [limit:20] [userlist:[name,id]]`, `stats listened [range] [limit:20] [userlist:[name,id]]`, `stats track [range]`, `stats skips`, `stats sources`, or `stats reports`.");
}
private Long parseUserId(String value)
{
if(value == null)
return null;
String cleaned = value.trim();
if(cleaned.startsWith("<@") && cleaned.endsWith(">"))
{
cleaned = cleaned.substring(2, cleaned.length() - 1);
if(cleaned.startsWith("!"))
cleaned = cleaned.substring(1);
}
if(!cleaned.matches("\\d{15,25}"))
return null;
try
{
return Long.parseLong(cleaned);
}
catch(NumberFormatException ex)
{
return null;
}
}
private List<Member> findMembersByPartialName(String query, List<Member> members)
{
String normalizedQuery = normalizeName(query);
List<Member> matches = new ArrayList<>();
if(normalizedQuery.isEmpty())
return matches;
for(Member member : members)
{
if(member.getUser().isBot())
continue;
if(nameMatches(normalizedQuery, member.getEffectiveName())
|| nameMatches(normalizedQuery, member.getUser().getName())
|| nameMatches(normalizedQuery, member.getNickname()))
{
matches.add(member);
}
}
return matches;
}
private boolean nameMatches(String normalizedQuery, String name)
{
if(name == null || name.isEmpty())
return false;
return normalizeName(name).contains(normalizedQuery);
}
private String normalizeName(String value)
{
return value == null ? "" : value.toLowerCase().replaceAll("[^a-z0-9]", "");
}
private String formatMemberMatches(List<Member> members)
{
StringBuilder builder = new StringBuilder();
int max = Math.min(5, members.size());
for(int i = 0; i < max; i++)
{
if(i > 0)
builder.append(", ");
Member member = members.get(i);
builder.append("`").append(member.getEffectiveName()).append("`");
}
if(members.size() > max)
builder.append(", and ").append(members.size() - max).append(" more");
return builder.toString();
}
private String formatProfileMatches(List<StatsRow> rows)
{
StringBuilder builder = new StringBuilder();
int max = Math.min(5, rows.size());
for(int i = 0; i < max; i++)
{
if(i > 0)
builder.append(", ");
builder.append("`").append(rows.get(i).getLabel()).append("`");
}
if(rows.size() > max)
builder.append(", and ").append(rows.size() - max).append(" more");
return builder.toString();
}
private void handleTrackStats(CommandEvent event, Options options)
{
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
AudioTrack track = handler == null ? null : handler.getPlayer().getPlayingTrack();
if(track == null)
{
event.replyWarning("No track is currently playing.");
CommandLogContext.setError("no_current_track");
return;
}
long count = bot.getDataLogService().getGuildTrackPlayCount(event.getGuild(), track, options.range);
EmbedBuilder eb = new EmbedBuilder()
.setColor(event.getSelfMember().getColor())
.setTitle(track.getInfo().title, track.getInfo().uri)
.setDescription("Range: **" + options.range.getLabel() + "**\nPlayed **" + count + "** time" + (count == 1L ? "" : "s") + " on this server.");
if(track.getInfo().author != null && !track.getInfo().author.isEmpty())
eb.setFooter("Source: " + track.getInfo().author, null);
event.getChannel().sendMessageEmbeds(eb.build()).queue();
CommandLogContext.setMeta(new JSONObject().put("subcommand", "track").put("play_count", count));
}
private void handleTrackListStats(CommandEvent event, String title, Options options, boolean listened)
{
List<Long> userIds = resolveUserList(event, options.userQueries);
if(userIds == null)
return;
if(!userIds.isEmpty() && !canViewUserList(event, userIds))
{
event.replyError("You can only filter by other members if you can manage this server.");
CommandLogContext.setError("missing_manage_server");
return;
}
List<StatsRow> rows = listened
? bot.getDataLogService().getTopListenedTracks(event.getGuild().getIdLong(), options.range, options.source, userIds, options.limit)
: bot.getDataLogService().getTopRequestedTracks(event.getGuild().getIdLong(), options.range, options.source, userIds, options.limit);
if(rows.isEmpty())
{
event.replyWarning("No playback analytics found for that report yet.");
CommandLogContext.setError("no_data");
return;
}
String description = "Range: **" + options.range.getLabel() + "**"
+ (options.source == null ? "" : "\nSource: **" + options.source + "**")
+ (userIds.isEmpty() ? "\nUsers: **all**" : "\nUsers: **" + userIds.size() + " selected**");
EmbedBuilder eb = new EmbedBuilder()
.setTitle(title)
.setColor(event.getSelfMember().getColorRaw())
.setDescription(description);
StatsMessageFormatter.addChunkedField(eb,
listened ? "Tracks by listening time" : "Tracks by plays",
listened
? StatsMessageFormatter.trackListenList(rows, options.limit)
: StatsMessageFormatter.countList(rows, "plays", options.limit),
false);
event.getChannel().sendMessageEmbeds(eb.build()).queue();
CommandLogContext.setMeta(new JSONObject().put("range", options.range.getKey())
.put("source", options.source == null ? JSONObject.NULL : options.source)
.put("user_count", userIds.size())
.put("limit", options.limit)
.put("listened", listened));
}
private void handleReports(CommandEvent event, List<String> tokens)
{
if(tokens.isEmpty())
{
event.reply("Use `stats reports set <channel> <monthly|yearly|off>` or `stats reports now [month|year|YYYY-MM|MM/YYYY|YYYY]`.");
return;
}
String action = tokens.remove(0).toLowerCase();
if("now".equals(action))
{
if(!canManageServer(event.getMember()))
{
event.replyError("You need Manage Server to send stats reports.");
CommandLogContext.setError("missing_manage_server");
return;
}
StatsTimeRange range = parseReportRange(tokens);
if(range == null)
{
event.replyError("Use `stats reports now month`, `stats reports now year`, `stats reports now 2026-04`, `stats reports now 04/2026`, or `stats reports now 2025`.");
CommandLogContext.setError("invalid_report_range");
return;
}
boolean sent = bot.getStatsReportScheduler().sendReport(event.getGuild(), event.getTextChannel(), range);
if(sent)
{
event.replySuccess("Stats report generated for `" + range.getLabel() + "`.");
CommandLogContext.setMeta(new JSONObject().put("subcommand", "reports_now").put("range", range.getKey()));
}
else
{
event.replyWarning("No playback analytics found for that report yet.");
CommandLogContext.setError("no_data");
}
return;
}
if(!"set".equals(action))
{
event.replyWarning("Unknown reports action. Use `set` or `now`.");
CommandLogContext.setError("unknown_reports_action");
return;
}
if(!canManageServer(event.getMember()))
{
event.replyError("You need Manage Server to configure stats reports.");
CommandLogContext.setError("missing_manage_server");
return;
}
if(tokens.isEmpty())
{
event.replyError("Please include a channel and frequency, or `off`.");
CommandLogContext.setError("missing_report_settings");
return;
}
Settings settings = event.getClient().getSettingsFor(event.getGuild());
if(tokens.size() == 1 && "off".equalsIgnoreCase(tokens.get(0)))
{
settings.setStatsReportFrequency(StatsReportFrequency.OFF);
settings.setStatsReportChannel(null);
event.replySuccess("Stats reports are now disabled.");
CommandLogContext.setMeta(new JSONObject().put("subcommand", "reports_set").put("frequency", "OFF"));
return;
}
if(tokens.size() < 2)
{
event.replyError("Use `stats reports set <channel> <monthly|yearly|off>`.");
CommandLogContext.setError("missing_report_frequency");
return;
}
String frequencyToken = tokens.remove(tokens.size() - 1);
StatsReportFrequency frequency = StatsReportFrequency.parse(frequencyToken);
if(frequency == StatsReportFrequency.OFF)
{
settings.setStatsReportFrequency(StatsReportFrequency.OFF);
settings.setStatsReportChannel(null);
event.replySuccess("Stats reports are now disabled.");
CommandLogContext.setMeta(new JSONObject().put("subcommand", "reports_set").put("frequency", "OFF"));
return;
}
String channelText = String.join(" ", tokens);
@SuppressWarnings({"unchecked", "rawtypes"})
List<TextChannel> channels = (List)FinderUtil.findTextChannels(channelText, event.getGuild());
if(channels.isEmpty())
{
event.replyWarning("No text channel found matching `" + channelText + "`.");
CommandLogContext.setError("text_channel_not_found");
return;
}
if(channels.size() > 1)
{
event.replyWarning("Multiple text channels matched `" + channelText + "`. Please be more specific.");
CommandLogContext.setError("text_channel_ambiguous");
return;
}
settings.setStatsReportChannel(channels.get(0));
settings.setStatsReportFrequency(frequency);
event.replySuccess("Stats reports will be posted " + frequency.name().toLowerCase() + " in " + channels.get(0).getAsMention() + ".");
CommandLogContext.setMeta(new JSONObject().put("subcommand", "reports_set")
.put("channel_id", channels.get(0).getId()).put("frequency", frequency.name()));
}
private StatsTimeRange parseReportRange(List<String> tokens)
{
if(tokens.isEmpty())
return StatsTimeRange.previousMonth();
String value = String.join(" ", tokens).trim().toLowerCase();
if(value.isEmpty() || "month".equals(value) || "monthly".equals(value))
return StatsTimeRange.previousMonth();
if("year".equals(value) || "yearly".equals(value))
return StatsTimeRange.previousYear();
if(value.matches("\\d{4}"))
return StatsTimeRange.year(Integer.parseInt(value));
if(value.matches("\\d{4}-\\d{1,2}"))
{
String[] parts = value.split("-");
return StatsTimeRange.month(Integer.parseInt(parts[0]), Integer.parseInt(parts[1]));
}
if(value.matches("\\d{1,2}/\\d{4}"))
{
String[] parts = value.split("/");
return StatsTimeRange.month(Integer.parseInt(parts[1]), Integer.parseInt(parts[0]));
}
return null;
}
private void sendSummary(CommandEvent event, String title, Options options, Long userId, Section section)
{
StatsSummary summary = bot.getDataLogService().getStatsSummary(event.getGuild().getIdLong(), options.range, options.source, userId);
if(summary.isEmpty())
{
event.replyWarning("No playback analytics found for that report yet.");
CommandLogContext.setError("no_data");
return;
}
EmbedBuilder eb = userId == null
? StatsMessageFormatter.summaryEmbed(title, summary, event.getSelfMember().getColorRaw())
: StatsMessageFormatter.personalEmbed(title, summary, event.getSelfMember().getColorRaw());
if(section == Section.SKIPS)
{
eb.clearFields();
StatsMessageFormatter.addChunkedField(eb, "Skip actions", StatsMessageFormatter.countList(summary.getSkipActions(), "skips"), false);
StatsMessageFormatter.addChunkedField(eb, "Skip votes", StatsMessageFormatter.countList(summary.getSkipVotes(), "votes"), false);
}
else if(section == Section.SOURCES)
{
eb.clearFields();
StatsMessageFormatter.addChunkedField(eb, "Top sources", StatsMessageFormatter.countList(summary.getTopSources(), "plays"), false);
}
event.getChannel().sendMessageEmbeds(eb.build()).queue();
CommandLogContext.setMeta(new JSONObject().put("range", options.range.getKey())
.put("source", options.source == null ? JSONObject.NULL : options.source)
.put("target_user_id", userId == null ? JSONObject.NULL : userId));
}
private Options parseOptions(List<String> tokens)
{
StatsTimeRange range = StatsTimeRange.all();
String source = null;
int limit = 5;
List<String> remaining = new ArrayList<>();
List<String> userQueries = new ArrayList<>();
for(String token : tokens)
{
if(token == null || token.isEmpty())
continue;
if(token.toLowerCase().startsWith("source:"))
{
source = StatsFilters.normalizeSource(token.substring("source:".length()));
continue;
}
String lower = token.toLowerCase();
if(lower.startsWith("limit:") || lower.startsWith("top:") || lower.startsWith("n:"))
{
Integer parsedLimit = parseLimit(token.substring(token.indexOf(':') + 1));
if(parsedLimit != null)
limit = parsedLimit;
continue;
}
if(lower.startsWith("users:") || lower.startsWith("userlist:"))
{
String value = token.substring(token.indexOf(':') + 1).trim();
if(value.startsWith("[") && value.endsWith("]"))
value = value.substring(1, value.length() - 1);
for(String part : value.split(","))
if(!part.trim().isEmpty())
userQueries.add(part.trim());
continue;
}
StatsTimeRange parsedRange = StatsTimeRange.parse(token);
if(parsedRange != null)
{
range = parsedRange;
continue;
}
Integer parsedLimit = parseLimit(token);
if(parsedLimit != null)
{
limit = parsedLimit;
continue;
}
remaining.add(token);
}
return new Options(range, source, remaining, userQueries, limit);
}
private Integer parseLimit(String token)
{
if(token == null || !token.matches("\\d+"))
return null;
int value = Integer.parseInt(token);
if(value < 1)
return 1;
return Math.min(value, 25);
}
private List<Long> resolveUserList(CommandEvent event, List<String> queries)
{
List<Long> userIds = new ArrayList<>();
for(String query : queries)
{
Long userId = parseUserId(query);
if(userId != null)
{
userIds.add(userId);
continue;
}
List<Member> members = FinderUtil.findMembers(query, event.getGuild());
if(members.isEmpty())
members = findMembersByPartialName(query, event.getGuild().getMembers());
if(members.isEmpty())
{
List<StatsRow> profileMatches = bot.getDataLogService().findUsersByProfile(event.getGuild().getIdLong(), query, 6);
if(profileMatches.size() == 1)
{
userIds.add(profileMatches.get(0).getId());
continue;
}
if(profileMatches.size() > 1)
{
event.replyWarning("Multiple analytics users matched `" + query + "`: " + formatProfileMatches(profileMatches) + ". Please be more specific or use a mention.");
CommandLogContext.setError("member_ambiguous");
return null;
}
event.replyWarning("No member found matching `" + query + "`. Try a mention or Discord user ID.");
CommandLogContext.setError("member_not_found");
return null;
}
if(members.size() > 1)
{
event.replyWarning("Multiple members matched `" + query + "`: " + formatMemberMatches(members) + ". Please be more specific or use a mention.");
CommandLogContext.setError("member_ambiguous");
return null;
}
userIds.add(members.get(0).getIdLong());
}
return userIds;
}
private boolean canViewUserList(CommandEvent event, List<Long> userIds)
{
for(Long userId : userIds)
if(userId != event.getAuthor().getIdLong())
return canManageServer(event.getMember());
return true;
}
private boolean canManageServer(Member member)
{
return member != null && member.hasPermission(Permission.MANAGE_SERVER);
}
private enum Section
{
ALL,
SKIPS,
SOURCES
}
private static class Options
{
private final StatsTimeRange range;
private final String source;
private final List<String> remaining;
private final List<String> userQueries;
private final int limit;
private Options(StatsTimeRange range, String source, List<String> remaining, List<String> userQueries, int limit)
{
this.range = range == null ? StatsTimeRange.all() : range;
this.source = source;
this.remaining = remaining;
this.userQueries = userQueries;
this.limit = limit;
}
}
}
@@ -20,8 +20,10 @@ import com.jagrosh.jlyrics.LyricsClient;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.Permission;
import org.json.JSONObject;
/**
*
@@ -44,6 +46,7 @@ public class LyricsCmd extends MusicCommand
@Override
public void doCommand(CommandEvent event)
{
CommandLogContext.setManualLogging();
String title;
if(event.getArgs().isEmpty())
{
@@ -53,6 +56,7 @@ public class LyricsCmd extends MusicCommand
else
{
event.replyError("There must be music playing to use that!");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "no_music_playing"));
return;
}
}
@@ -64,6 +68,7 @@ public class LyricsCmd extends MusicCommand
if(lyrics == null)
{
event.replyError("Lyrics for `" + title + "` could not be found!" + (event.getArgs().isEmpty() ? " Try entering the song name manually (`lyrics [song name]`)" : ""));
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "lyrics_not_found"));
return;
}
@@ -95,6 +100,15 @@ public class LyricsCmd extends MusicCommand
}
else
event.reply(eb.setDescription(lyrics.getContent()).build());
logCommandEvent(event, "OK", new JSONObject().put("title", lyrics.getTitle()));
});
}
private void logCommandEvent(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -19,8 +19,10 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import org.json.JSONObject;
/**
*
@@ -46,10 +48,14 @@ public class NowplayingCmd extends MusicCommand
{
event.getMessage().getChannel().sendMessage(handler.getNoMusicPlaying(event.getJDA())).queue();
bot.getNowplayingHandler().clearLastNPMessage(event.getGuild());
CommandLogContext.setMeta(new JSONObject().put("playing", false));
}
else
{
event.getMessage().getChannel().sendMessage(m).queue(msg -> bot.getNowplayingHandler().setLastNPMessage(msg));
CommandLogContext.setMeta(new JSONObject()
.put("playing", true)
.put("title", handler.getPlayer().getPlayingTrack() == null ? JSONObject.NULL : handler.getPlayer().getPlayingTrack().getInfo().title));
}
}
}
@@ -30,12 +30,14 @@ import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.QueuedTrack;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import java.util.concurrent.TimeUnit;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.exceptions.PermissionException;
import org.json.JSONObject;
/**
*
@@ -64,6 +66,7 @@ public class PlayCmd extends MusicCommand
@Override
public void doCommand(CommandEvent event)
{
CommandLogContext.setManualLogging();
if(event.getArgs().isEmpty() && event.getMessage().getAttachments().isEmpty())
{
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
@@ -73,9 +76,20 @@ public class PlayCmd extends MusicCommand
{
handler.getPlayer().setPaused(false);
event.replySuccess("Resumed **"+handler.getPlayer().getPlayingTrack().getInfo().title+"**.");
if(bot.getDataLogService() != null)
{
JSONObject meta = new JSONObject()
.put("position_ms", handler.getPlayer().getPlayingTrack().getPosition());
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"RESUME", null, null, null, null, meta.toString());
logCommandEvent(event, getName(), event.getArgs(), "OK", meta);
}
}
else
{
event.replyError("Only DJs can unpause the player!");
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", "not_dj"));
}
return;
}
StringBuilder builder = new StringBuilder(event.getClient().getWarning()+" Play Commands:\n");
@@ -84,6 +98,7 @@ public class PlayCmd extends MusicCommand
for(Command cmd: children)
builder.append("\n`").append(event.getClient().getPrefix()).append(name).append(" ").append(cmd.getName()).append(" ").append(cmd.getArguments()).append("` - ").append(cmd.getHelp());
event.reply(builder.toString());
logCommandEvent(event, getName(), event.getArgs(), "OK", new JSONObject().put("help", true));
return;
}
String args = event.getArgs().startsWith("<") && event.getArgs().endsWith(">")
@@ -111,10 +126,14 @@ public class PlayCmd extends MusicCommand
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" This track (**"+track.getInfo().title+"**) is longer than the allowed maximum: `"
+ TimeUtil.formatTime(track.getDuration())+"` > `"+ TimeUtil.formatTime(bot.getConfig().getMaxSeconds()*1000)+"`")).queue();
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", "track_too_long"));
return;
}
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
int pos = handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1;
RequestMetadata rm = RequestMetadata.fromResultHandler(track, event, "PLAY", null, event.getArgs());
int pos = handler.addTrack(new QueuedTrack(track, rm))+1;
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueAdd(event.getGuild(), event.getAuthor(), track, "PLAY", pos, event.getArgs(), null);
String addMsg = FormatUtil.filter(event.getClient().getSuccess()+" Added **"+track.getInfo().title
+"** (`"+ TimeUtil.formatTime(track.getDuration())+"`) "+(pos==0?"to begin playing":" to the queue at position "+pos));
if(playlist==null || !event.getSelfMember().hasPermission(event.getTextChannel(), Permission.MESSAGE_ADD_REACTION))
@@ -137,6 +156,7 @@ public class PlayCmd extends MusicCommand
try{ m.clearReactions().queue(); }catch(PermissionException ignore) {}
}).build().display(m);
}
logCommandEvent(event, getName(), event.getArgs(), "OK", new JSONObject().put("queue_position", pos));
}
private int loadPlaylist(AudioPlaylist playlist, AudioTrack exclude)
@@ -146,7 +166,10 @@ public class PlayCmd extends MusicCommand
if(!bot.getConfig().isTooLong(track) && !track.equals(exclude))
{
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)));
RequestMetadata rm = RequestMetadata.fromResultHandler(track, event, "PLAYLIST", playlist == null ? null : playlist.getName(), event.getArgs());
int pos = handler.addTrack(new QueuedTrack(track, rm))+1;
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueAdd(event.getGuild(), event.getAuthor(), track, "PLAYLIST", pos, event.getArgs(), playlist == null ? null : playlist.getName());
count[0]++;
}
});
@@ -179,11 +202,13 @@ public class PlayCmd extends MusicCommand
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" The playlist "+(playlist.getName()==null ? "" : "(**"+playlist.getName()
+"**) ")+" could not be loaded or contained 0 entries")).queue();
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", "playlist_empty"));
}
else if(count==0)
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" All entries in this playlist "+(playlist.getName()==null ? "" : "(**"+playlist.getName()
+"**) ")+"were longer than the allowed maximum (`"+bot.getConfig().getMaxTime()+"`)")).queue();
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", "playlist_entries_too_long"));
}
else
{
@@ -192,6 +217,7 @@ public class PlayCmd extends MusicCommand
+ playlist.getTracks().size()+"` entries; added to the queue!"
+ (count<playlist.getTracks().size() ? "\n"+event.getClient().getWarning()+" Tracks longer than the allowed maximum (`"
+ bot.getConfig().getMaxTime()+"`) have been omitted." : ""))).queue();
logCommandEvent(event, getName(), event.getArgs(), "OK", new JSONObject().put("playlist_name", playlist.getName()).put("added_count", count));
}
}
}
@@ -200,7 +226,13 @@ public class PlayCmd extends MusicCommand
public void noMatches()
{
if(ytsearch)
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" No results found for `"+event.getArgs()+"`.")).queue();
if(bot.getDataLogService() != null)
bot.getDataLogService().logSearchEventWithMeta(event.getGuild(), event.getAuthor(), event.getArgs(), null, null,
new JSONObject().put("error_reason", "no_matches").toString());
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", "no_matches"));
}
else
bot.getPlayerManager().loadItemOrdered(event.getGuild(), "ytsearch:"+event.getArgs(), new ResultHandler(m,event,true));
}
@@ -212,6 +244,10 @@ public class PlayCmd extends MusicCommand
m.editMessage(event.getClient().getError()+" Error loading: "+throwable.getMessage()).queue();
else
m.editMessage(event.getClient().getError()+" Error loading track.").queue();
if(bot.getDataLogService() != null)
bot.getDataLogService().logSearchEventWithMeta(event.getGuild(), event.getAuthor(), event.getArgs(), null, null,
new JSONObject().put("error_reason", throwable.getMessage()).toString());
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", throwable.getMessage()));
}
}
@@ -231,21 +267,30 @@ public class PlayCmd extends MusicCommand
@Override
public void doCommand(CommandEvent event)
{
CommandLogContext.setManualLogging();
if(event.getArgs().isEmpty())
{
event.reply(event.getClient().getError()+" Please include a playlist name.");
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", "missing_playlist"));
return;
}
Playlist playlist = bot.getPlaylistLoader().getPlaylist(event.getArgs());
if(playlist==null)
{
event.replyError("I could not find `"+event.getArgs()+".txt` in the Playlists folder.");
logCommandEvent(event, getName(), event.getArgs(), "ERROR", new JSONObject().put("error_reason", "playlist_not_found"));
return;
}
event.getChannel().sendMessage(loadingEmoji+" Loading playlist **"+event.getArgs()+"**... ("+playlist.getItems().size()+" items)").queue(m ->
{
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
playlist.loadTracks(bot.getPlayerManager(), (at)->handler.addTrack(new QueuedTrack(at, RequestMetadata.fromResultHandler(at, event))), () -> {
playlist.loadTracks(bot.getPlayerManager(), (at) ->
{
RequestMetadata rm = RequestMetadata.fromResultHandler(at, event, "PLAYLIST", event.getArgs(), event.getArgs());
int pos = handler.addTrack(new QueuedTrack(at, rm))+1;
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueAdd(event.getGuild(), event.getAuthor(), at, "PLAYLIST", pos, event.getArgs(), event.getArgs());
}, () -> {
StringBuilder builder = new StringBuilder(playlist.getTracks().isEmpty()
? event.getClient().getWarning()+" No tracks were loaded!"
: event.getClient().getSuccess()+" Loaded **"+playlist.getTracks().size()+"** tracks!");
@@ -256,8 +301,25 @@ public class PlayCmd extends MusicCommand
if(str.length()>2000)
str = str.substring(0,1994)+" (...)";
m.editMessage(FormatUtil.filter(str)).queue();
if(bot.getDataLogService() != null)
{
playlist.getErrors().forEach(err ->
bot.getDataLogService().logPlaylistError(event.getGuild(), event.getAuthor(), event.getArgs(), err.getItem(), err.getReason()));
}
logCommandEvent(event, getName(), event.getArgs(), "OK", new JSONObject()
.put("playlist_name", event.getArgs())
.put("loaded_count", playlist.getTracks().size())
.put("error_count", playlist.getErrors().size()));
});
});
}
}
private void logCommandEvent(CommandEvent event, String commandName, String args, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), commandName,
args, result, meta == null ? null : meta.toString());
}
}
@@ -19,6 +19,8 @@ import java.util.List;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
/**
*
@@ -45,19 +47,27 @@ public class PlaylistsCmd extends MusicCommand
if(!bot.getPlaylistLoader().folderExists())
{
event.reply(event.getClient().getWarning()+" Playlists folder does not exist and could not be created!");
CommandLogContext.setError("playlist_folder_missing");
return;
}
List<String> list = bot.getPlaylistLoader().getPlaylistNames();
if(list==null)
{
event.reply(event.getClient().getError()+" Failed to load available playlists!");
CommandLogContext.setError("playlist_list_failed");
}
else if(list.isEmpty())
{
event.reply(event.getClient().getWarning()+" There are no playlists in the Playlists folder!");
CommandLogContext.setMeta(new JSONObject().put("count", 0));
}
else
{
StringBuilder builder = new StringBuilder(event.getClient().getSuccess()+" Available playlists:\n");
list.forEach(str -> builder.append("`").append(str).append("` "));
builder.append("\nType `").append(event.getClient().getTextualPrefix()).append("play playlist <name>` to play a playlist");
event.reply(builder.toString());
CommandLogContext.setMeta(new JSONObject().put("count", list.size()));
}
}
}
@@ -23,6 +23,7 @@ import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.QueuedTrack;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.QueueType;
import com.jagrosh.jmusicbot.settings.RepeatMode;
import com.jagrosh.jmusicbot.settings.Settings;
@@ -33,6 +34,7 @@ import net.dv8tion.jda.api.utils.messages.MessageCreateData;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Message;
import net.dv8tion.jda.api.exceptions.PermissionException;
import org.json.JSONObject;
/**
*
@@ -86,6 +88,7 @@ public class QueueCmd extends MusicCommand
if(nowp!=null)
bot.getNowplayingHandler().setLastNPMessage(m);
});
CommandLogContext.setMeta(new JSONObject().put("queue_size", 0));
return;
}
String[] songs = new String[list.size()];
@@ -97,6 +100,11 @@ public class QueueCmd extends MusicCommand
}
Settings settings = event.getClient().getSettingsFor(event.getGuild());
long fintotal = total;
CommandLogContext.setMeta(new JSONObject()
.put("queue_size", list.size())
.put("total_ms", total)
.put("repeat_mode", settings.getRepeatMode().name())
.put("queue_type", settings.getQueueType().name()));
builder.setText((i1,i2) -> getQueueTitle(ah, event.getClient().getSuccess(), songs.length, fintotal, settings.getRepeatMode(), settings.getQueueType()))
.setItems(songs)
.setUsers(event.getAuthor())
@@ -20,9 +20,11 @@ import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.QueuedTrack;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.User;
import org.json.JSONObject;
/**
*
@@ -48,15 +50,28 @@ public class RemoveCmd extends MusicCommand
if(handler.getQueue().isEmpty())
{
event.replyError("There is nothing in the queue!");
CommandLogContext.setError("empty_queue");
return;
}
if(event.getArgs().equalsIgnoreCase("all"))
{
int sizeBefore = handler.getQueue().size();
int count = handler.getQueue().removeAll(event.getAuthor().getIdLong());
if(count==0)
{
event.replyWarning("You don't have any songs in the queue!");
CommandLogContext.setError("no_entries_for_user");
}
else
event.replySuccess("Successfully removed your "+count+" entries.");
JSONObject meta = new JSONObject()
.put("removed_count", count)
.put("queue_size_before", sizeBefore)
.put("queue_size_after", handler.getQueue().size());
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), null,
"REMOVE_ALL", "SELF", null, null, null, meta.toString());
return;
}
int pos;
@@ -68,6 +83,7 @@ public class RemoveCmd extends MusicCommand
if(pos<1 || pos>handler.getQueue().size())
{
event.replyError("Position must be a valid integer between 1 and "+handler.getQueue().size()+"!");
CommandLogContext.setError("invalid_position");
return;
}
Settings settings = event.getClient().getSettingsFor(event.getGuild());
@@ -79,6 +95,11 @@ public class RemoveCmd extends MusicCommand
{
handler.getQueue().remove(pos-1);
event.replySuccess("Removed **"+qt.getTrack().getInfo().title+"** from the queue");
JSONObject meta = new JSONObject().put("position", pos);
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), qt.getTrack(),
"REMOVE", "SELF", pos, null, null, meta.toString());
}
else if(isDJ)
{
@@ -91,10 +112,16 @@ public class RemoveCmd extends MusicCommand
}
event.replySuccess("Removed **"+qt.getTrack().getInfo().title
+"** from the queue (requested by "+(u==null ? "someone" : "**"+u.getName()+"**")+")");
JSONObject meta = new JSONObject().put("position", pos).put("removed_by_dj", true);
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), qt.getTrack(),
"REMOVE", "DJ", pos, null, null, meta.toString());
}
else
{
event.replyError("You cannot remove **"+qt.getTrack().getInfo().title+"** because you didn't add it!");
CommandLogContext.setError("not_owner");
}
}
}
@@ -29,9 +29,11 @@ import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.QueuedTrack;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.Message;
import org.json.JSONObject;
/**
*
@@ -64,9 +66,11 @@ public class SearchCmd extends MusicCommand
@Override
public void doCommand(CommandEvent event)
{
CommandLogContext.setManualLogging();
if(event.getArgs().isEmpty())
{
event.replyError("Please include a query.");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "missing_query"));
return;
}
event.reply(searchingEmoji+" Searching... `["+event.getArgs()+"]`",
@@ -91,13 +95,21 @@ public class SearchCmd extends MusicCommand
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" This track (**"+track.getInfo().title+"**) is longer than the allowed maximum: `"
+ TimeUtil.formatTime(track.getDuration())+"` > `"+bot.getConfig().getMaxTime()+"`")).queue();
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "track_too_long"));
return;
}
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
int pos = handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1;
RequestMetadata rm = RequestMetadata.fromResultHandler(track, event, "SEARCH", null, event.getArgs());
int pos = handler.addTrack(new QueuedTrack(track, rm))+1;
if(bot.getDataLogService() != null)
{
bot.getDataLogService().logSearchSelection(event.getGuild(), event.getAuthor(), event.getArgs(), 1, track);
bot.getDataLogService().logQueueAdd(event.getGuild(), event.getAuthor(), track, "SEARCH", pos, event.getArgs(), null);
}
m.editMessage(FormatUtil.filter(event.getClient().getSuccess()+" Added **"+track.getInfo().title
+"** (`"+ TimeUtil.formatTime(track.getDuration())+"`) "+(pos==0 ? "to begin playing"
: " to the queue at position "+pos))).queue();
logCommandEvent(event, "OK", new JSONObject().put("queue_position", pos).put("selected_index", 1));
}
@Override
@@ -113,10 +125,17 @@ public class SearchCmd extends MusicCommand
{
event.replyWarning("This track (**"+track.getInfo().title+"**) is longer than the allowed maximum: `"
+ TimeUtil.formatTime(track.getDuration())+"` > `"+bot.getConfig().getMaxTime()+"`");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "track_too_long"));
return;
}
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
int pos = handler.addTrack(new QueuedTrack(track, RequestMetadata.fromResultHandler(track, event)))+1;
RequestMetadata rm = RequestMetadata.fromResultHandler(track, event, "SEARCH", null, event.getArgs());
int pos = handler.addTrack(new QueuedTrack(track, rm))+1;
if(bot.getDataLogService() != null)
{
bot.getDataLogService().logSearchSelection(event.getGuild(), event.getAuthor(), event.getArgs(), i, track);
bot.getDataLogService().logQueueAdd(event.getGuild(), event.getAuthor(), track, "SEARCH", pos, event.getArgs(), null);
}
event.replySuccess("Added **" + FormatUtil.filter(track.getInfo().title)
+ "** (`" + TimeUtil.formatTime(track.getDuration()) + "`) " + (pos==0 ? "to begin playing"
: " to the queue at position "+pos));
@@ -130,12 +149,17 @@ public class SearchCmd extends MusicCommand
builder.addChoices("`["+ TimeUtil.formatTime(track.getDuration())+"]` [**"+track.getInfo().title+"**]("+track.getInfo().uri+")");
}
builder.build().display(m);
logCommandEvent(event, "OK", new JSONObject().put("result_count", playlist.getTracks().size()));
}
@Override
public void noMatches()
{
m.editMessage(FormatUtil.filter(event.getClient().getWarning()+" No results found for `"+event.getArgs()+"`.")).queue();
if(bot.getDataLogService() != null)
bot.getDataLogService().logSearchEventWithMeta(event.getGuild(), event.getAuthor(), event.getArgs(), null, null,
new JSONObject().put("error_reason", "no_matches").toString());
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "no_matches"));
}
@Override
@@ -145,6 +169,18 @@ public class SearchCmd extends MusicCommand
m.editMessage(event.getClient().getError()+" Error loading: "+throwable.getMessage()).queue();
else
m.editMessage(event.getClient().getError()+" Error loading track.").queue();
if(bot.getDataLogService() != null)
bot.getDataLogService().logSearchEventWithMeta(event.getGuild(), event.getAuthor(), event.getArgs(), null, null,
new JSONObject().put("error_reason", throwable.getMessage()).toString());
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", throwable.getMessage()));
}
}
private void logCommandEvent(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -21,10 +21,12 @@ import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.RequestMetadata;
import com.jagrosh.jmusicbot.commands.DJCommand;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.TimeUtil;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.json.JSONObject;
/**
@@ -53,6 +55,7 @@ public class SeekCmd extends MusicCommand
if (!playingTrack.isSeekable())
{
event.replyError("This track is not seekable.");
CommandLogContext.setError("not_seekable");
return;
}
@@ -60,6 +63,7 @@ public class SeekCmd extends MusicCommand
if (!DJCommand.checkDJPermission(event) && playingTrack.getUserData(RequestMetadata.class).getOwner() != event.getAuthor().getIdLong())
{
event.replyError("You cannot seek **" + playingTrack.getInfo().title + "** because you didn't add it!");
CommandLogContext.setError("not_owner");
return;
}
@@ -68,6 +72,7 @@ public class SeekCmd extends MusicCommand
if (seekTime == null)
{
event.replyError("Invalid seek! Expected format: " + arguments + "\nExamples: `1:02:23` `+1:10` `-90`, `1h10m`, `+90s`");
CommandLogContext.setError("invalid_seek");
return;
}
@@ -78,6 +83,7 @@ public class SeekCmd extends MusicCommand
if (seekMilliseconds > trackDuration)
{
event.replyError("Cannot seek to `" + TimeUtil.formatTime(seekMilliseconds) + "` because the current track is `" + TimeUtil.formatTime(trackDuration) + "` long!");
CommandLogContext.setError("seek_out_of_bounds");
return;
}
@@ -89,8 +95,16 @@ public class SeekCmd extends MusicCommand
{
event.replyError("An error occurred while trying to seek: " + e.getMessage());
LOG.warn("Failed to seek track " + playingTrack.getIdentifier(), e);
CommandLogContext.setError("seek_failed");
return;
}
event.replySuccess("Successfully seeked to `" + TimeUtil.formatTime(playingTrack.getPosition()) + "/" + TimeUtil.formatTime(playingTrack.getDuration()) + "`!");
JSONObject meta = new JSONObject()
.put("seek_from_ms", currentPosition)
.put("seek_to_ms", playingTrack.getPosition());
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), playingTrack,
"SEEK", null, null, null, null, meta.toString());
}
}
@@ -19,6 +19,8 @@ import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
/**
*
@@ -40,17 +42,28 @@ public class ShuffleCmd extends MusicCommand
public void doCommand(CommandEvent event)
{
AudioHandler handler = (AudioHandler)event.getGuild().getAudioManager().getSendingHandler();
int sizeBefore = handler.getQueue().size();
int s = handler.getQueue().shuffle(event.getAuthor().getIdLong());
switch (s)
{
case 0:
event.replyError("You don't have any music in the queue to shuffle!");
CommandLogContext.setError("no_entries_to_shuffle");
break;
case 1:
event.replyWarning("You only have one song in the queue!");
CommandLogContext.setMeta(new JSONObject().put("shuffled_count", 1));
break;
default:
event.replySuccess("You successfully shuffled your "+s+" entries.");
JSONObject meta = new JSONObject()
.put("shuffled_count", s)
.put("queue_size_before", sizeBefore)
.put("queue_size_after", handler.getQueue().size());
CommandLogContext.setMeta(meta);
if(bot.getDataLogService() != null)
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), null,
"SHUFFLE", null, null, null, null, meta.toString());
break;
}
}
@@ -20,7 +20,9 @@ import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.audio.AudioHandler;
import com.jagrosh.jmusicbot.audio.RequestMetadata;
import com.jagrosh.jmusicbot.commands.MusicCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.FormatUtil;
import org.json.JSONObject;
/**
*
@@ -50,6 +52,13 @@ public class SkipCmd extends MusicCommand
if(event.getAuthor().getIdLong() == rm.getOwner() || skipRatio == 0)
{
event.reply(event.getClient().getSuccess()+" Skipped **"+handler.getPlayer().getPlayingTrack().getInfo().title+"**");
if(bot.getDataLogService() != null)
{
JSONObject meta = new JSONObject().put("reason", "REQUESTER");
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"SKIP_REQUESTER", null, null, null, null, meta.toString());
CommandLogContext.setMeta(meta);
}
handler.getPlayer().stopTrack();
}
else
@@ -63,15 +72,36 @@ public class SkipCmd extends MusicCommand
{
msg = event.getClient().getSuccess()+" You voted to skip the song `[";
handler.getVotes().add(event.getAuthor().getId());
if(bot.getDataLogService() != null)
{
JSONObject meta = new JSONObject().put("vote", "added");
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"SKIP_VOTE", null, null, null, null, meta.toString());
}
}
int skippers = (int)event.getSelfMember().getVoiceState().getChannel().getMembers().stream()
.filter(m -> handler.getVotes().contains(m.getUser().getId())).count();
int required = (int)Math.ceil(listeners * skipRatio);
msg += skippers + " votes, " + required + "/" + listeners + " needed]`";
CommandLogContext.setMeta(new JSONObject()
.put("votes", skippers)
.put("required", required)
.put("listeners", listeners));
if(skippers>=required)
{
msg += "\n" + event.getClient().getSuccess() + " Skipped **" + handler.getPlayer().getPlayingTrack().getInfo().title
+ "** " + (rm.getOwner() == 0L ? "(autoplay)" : "(requested by **" + FormatUtil.formatUsername(rm.user) + "**)");
if(bot.getDataLogService() != null)
{
JSONObject meta = new JSONObject()
.put("reason", "VOTE")
.put("votes", skippers)
.put("required", required)
.put("listeners", listeners);
bot.getDataLogService().logQueueEventWithMeta(event.getGuild(), event.getAuthor(), handler.getPlayer().getPlayingTrack(),
"SKIP_VOTE_ACTION", null, null, null, null, meta.toString());
CommandLogContext.setMeta(meta);
}
handler.getPlayer().stopTrack();
}
event.reply(msg);
@@ -18,7 +18,9 @@ package com.jagrosh.jmusicbot.commands.owner;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.settings.Settings;
import org.json.JSONObject;
/**
*
@@ -26,11 +28,9 @@ import com.jagrosh.jmusicbot.settings.Settings;
*/
public class AutoplaylistCmd extends OwnerCommand
{
private final Bot bot;
public AutoplaylistCmd(Bot bot)
{
this.bot = bot;
super(bot);
this.guildOnly = true;
this.name = "autoplaylist";
this.arguments = "<name|NONE>";
@@ -39,30 +39,34 @@ public class AutoplaylistCmd extends OwnerCommand
}
@Override
public void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(event.getArgs().isEmpty())
{
event.reply(event.getClient().getError()+" Please include a playlist name or NONE");
CommandLogContext.setError("missing_playlist");
return;
}
Settings settings = event.getClient().getSettingsFor(event.getGuild());
String before = settings.getDefaultPlaylist();
if(event.getArgs().equalsIgnoreCase("none"))
{
Settings settings = event.getClient().getSettingsFor(event.getGuild());
settings.setDefaultPlaylist(null);
event.reply(event.getClient().getSuccess()+" Cleared the default playlist for **"+event.getGuild().getName()+"**");
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", JSONObject.NULL));
return;
}
String pname = event.getArgs().replaceAll("\\s+", "_");
if(bot.getPlaylistLoader().getPlaylist(pname)==null)
{
event.reply(event.getClient().getError()+" Could not find `"+pname+".txt`!");
CommandLogContext.setError("playlist_not_found");
}
else
{
Settings settings = event.getClient().getSettingsFor(event.getGuild());
settings.setDefaultPlaylist(pname);
event.reply(event.getClient().getSuccess()+" The default playlist for **"+event.getGuild().getName()+"** is now `"+pname+"`");
CommandLogContext.setMeta(new JSONObject().put("before", before).put("after", pname));
}
}
}
@@ -16,15 +16,16 @@
package com.jagrosh.jmusicbot.commands.owner;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jdautilities.commons.JDAUtilitiesInfo;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.OtherUtil;
import com.sedmelluq.discord.lavaplayer.tools.PlayerLibrary;
import net.dv8tion.jda.api.JDAInfo;
import net.dv8tion.jda.api.Permission;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import net.dv8tion.jda.api.utils.FileUpload;
import org.json.JSONObject;
/**
*
@@ -35,11 +36,9 @@ public class DebugCmd extends OwnerCommand
private final static String[] PROPERTIES = {"java.version", "java.vm.name", "java.vm.specification.version",
"java.runtime.name", "java.runtime.version", "java.specification.version", "os.arch", "os.name"};
private final Bot bot;
public DebugCmd(Bot bot)
{
this.bot = bot;
super(bot);
this.name = "debug";
this.help = "shows debug info";
this.aliases = bot.getConfig().getAliases(this.name);
@@ -47,8 +46,9 @@ public class DebugCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
CommandLogContext.setMeta(new JSONObject().put("channel_type", event.getChannelType().name()));
StringBuilder sb = new StringBuilder();
sb.append("```\nSystem Properties:");
for(String key: PROPERTIES)
@@ -66,7 +66,7 @@ public class DebugCmd extends OwnerCommand
.append("\n UpdateAlerts = ").append(bot.getConfig().useUpdateAlerts());
sb.append("\n\nDependency Information:")
.append("\n JDA Version = ").append(JDAInfo.VERSION)
.append("\n JDA-Utilities Version = ").append(JDAUtilitiesInfo.VERSION)
.append("\n JDA-Utilities Version = ").append(OtherUtil.getCommandLibraryVersion())
.append("\n Lavaplayer Version = ").append(PlayerLibrary.VERSION);
long total = Runtime.getRuntime().totalMemory() / 1024 / 1024;
long used = total - (Runtime.getRuntime().freeMemory() / 1024 / 1024);
@@ -20,7 +20,9 @@ import javax.script.ScriptEngineManager;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import net.dv8tion.jda.api.entities.channel.ChannelType;
import org.json.JSONObject;
/**
*
@@ -28,12 +30,11 @@ import net.dv8tion.jda.api.entities.channel.ChannelType;
*/
public class EvalCmd extends OwnerCommand
{
private final Bot bot;
private final String engine;
public EvalCmd(Bot bot)
{
this.bot = bot;
super(bot);
this.name = "eval";
this.help = "evaluates nashorn code";
this.aliases = bot.getConfig().getAliases(this.name);
@@ -42,13 +43,15 @@ public class EvalCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
CommandLogContext.setManualLogging();
ScriptEngine se = new ScriptEngineManager().getEngineByName(engine);
if(se == null)
{
event.replyError("The eval engine provided in the config (`"+engine+"`) doesn't exist. This could be due to an invalid "
+ "engine name, or the engine not existing in your version of java (`"+System.getProperty("java.version")+"`).");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "engine_not_found"));
return;
}
se.put("bot", bot);
@@ -61,11 +64,21 @@ public class EvalCmd extends OwnerCommand
try
{
event.reply(event.getClient().getSuccess()+" Evaluated Successfully:\n```\n"+se.eval(event.getArgs())+" ```");
logCommandEvent(event, "OK", new JSONObject().put("invoked", true));
}
catch(Exception e)
{
event.reply(event.getClient().getError()+" An exception was thrown:\n```\n"+e+" ```");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "eval_exception"));
}
}
private void logCommandEvent(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
null, result, meta == null ? null : meta.toString());
}
}
@@ -17,11 +17,12 @@ package com.jagrosh.jmusicbot.commands.owner;
import java.io.IOException;
import java.util.List;
import com.jagrosh.jdautilities.command.Command;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist;
import org.json.JSONObject;
/**
*
@@ -29,10 +30,9 @@ import com.jagrosh.jmusicbot.playlist.PlaylistLoader.Playlist;
*/
public class PlaylistCmd extends OwnerCommand
{
private final Bot bot;
public PlaylistCmd(Bot bot)
{
this.bot = bot;
super(bot);
this.guildOnly = false;
this.name = "playlist";
this.arguments = "<append|delete|make|setdefault>";
@@ -48,19 +48,21 @@ public class PlaylistCmd extends OwnerCommand
}
@Override
public void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
StringBuilder builder = new StringBuilder(event.getClient().getWarning()+" Playlist Management Commands:\n");
for(Command cmd: this.children)
for(com.jagrosh.jdautilities.command.Command cmd: this.children)
builder.append("\n`").append(event.getClient().getPrefix()).append(name).append(" ").append(cmd.getName())
.append(" ").append(cmd.getArguments()==null ? "" : cmd.getArguments()).append("` - ").append(cmd.getHelp());
event.reply(builder.toString());
CommandLogContext.setMeta(new JSONObject().put("subcommands", this.children.length));
}
public class MakelistCmd extends OwnerCommand
{
public MakelistCmd()
{
super(PlaylistCmd.this.bot);
this.name = "make";
this.aliases = new String[]{"create"};
this.help = "makes a new playlist";
@@ -69,13 +71,14 @@ public class PlaylistCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
String pname = event.getArgs().replaceAll("\\s+", "_");
pname = pname.replaceAll("[*?|\\/\":<>]", "");
if(pname == null || pname.isEmpty())
{
event.replyError("Please provide a name for the playlist!");
CommandLogContext.setError("missing_playlist_name");
}
else if(bot.getPlaylistLoader().getPlaylist(pname) == null)
{
@@ -83,14 +86,19 @@ public class PlaylistCmd extends OwnerCommand
{
bot.getPlaylistLoader().createPlaylist(pname);
event.reply(event.getClient().getSuccess()+" Successfully created playlist `"+pname+"`!");
CommandLogContext.setMeta(new JSONObject().put("playlist_name", pname));
}
catch(IOException e)
{
event.reply(event.getClient().getError()+" I was unable to create the playlist: "+e.getLocalizedMessage());
CommandLogContext.setError("create_playlist_failed");
}
}
else
{
event.reply(event.getClient().getError()+" Playlist `"+pname+"` already exists!");
CommandLogContext.setError("playlist_exists");
}
}
}
@@ -98,6 +106,7 @@ public class PlaylistCmd extends OwnerCommand
{
public DeletelistCmd()
{
super(PlaylistCmd.this.bot);
this.name = "delete";
this.aliases = new String[]{"remove"};
this.help = "deletes an existing playlist";
@@ -106,21 +115,26 @@ public class PlaylistCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
String pname = event.getArgs().replaceAll("\\s+", "_");
if(bot.getPlaylistLoader().getPlaylist(pname)==null)
{
event.reply(event.getClient().getError()+" Playlist `"+pname+"` doesn't exist!");
CommandLogContext.setError("playlist_not_found");
}
else
{
try
{
bot.getPlaylistLoader().deletePlaylist(pname);
event.reply(event.getClient().getSuccess()+" Successfully deleted playlist `"+pname+"`!");
CommandLogContext.setMeta(new JSONObject().put("playlist_name", pname));
}
catch(IOException e)
{
event.reply(event.getClient().getError()+" I was unable to delete the playlist: "+e.getLocalizedMessage());
CommandLogContext.setError("delete_playlist_failed");
}
}
}
@@ -130,6 +144,7 @@ public class PlaylistCmd extends OwnerCommand
{
public AppendlistCmd()
{
super(PlaylistCmd.this.bot);
this.name = "append";
this.aliases = new String[]{"add"};
this.help = "appends songs to an existing playlist";
@@ -138,18 +153,22 @@ public class PlaylistCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
String[] parts = event.getArgs().split("\\s+", 2);
if(parts.length<2)
{
event.reply(event.getClient().getError()+" Please include a playlist name and URLs to add!");
CommandLogContext.setError("missing_append_args");
return;
}
String pname = parts[0];
Playlist playlist = bot.getPlaylistLoader().getPlaylist(pname);
if(playlist==null)
{
event.reply(event.getClient().getError()+" Playlist `"+pname+"` doesn't exist!");
CommandLogContext.setError("playlist_not_found");
}
else
{
StringBuilder builder = new StringBuilder();
@@ -166,10 +185,12 @@ public class PlaylistCmd extends OwnerCommand
{
bot.getPlaylistLoader().writePlaylist(pname, builder.toString());
event.reply(event.getClient().getSuccess()+" Successfully added "+urls.length+" items to playlist `"+pname+"`!");
CommandLogContext.setMeta(new JSONObject().put("playlist_name", pname).put("added_count", urls.length));
}
catch(IOException e)
{
event.reply(event.getClient().getError()+" I was unable to append to the playlist: "+e.getLocalizedMessage());
CommandLogContext.setError("append_playlist_failed");
}
}
}
@@ -191,6 +212,7 @@ public class PlaylistCmd extends OwnerCommand
{
public ListCmd()
{
super(PlaylistCmd.this.bot);
this.name = "all";
this.aliases = new String[]{"available","list"};
this.help = "lists all available playlists";
@@ -198,25 +220,33 @@ public class PlaylistCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(!bot.getPlaylistLoader().folderExists())
bot.getPlaylistLoader().createFolder();
if(!bot.getPlaylistLoader().folderExists())
{
event.reply(event.getClient().getWarning()+" Playlists folder does not exist and could not be created!");
CommandLogContext.setError("playlist_folder_missing");
return;
}
List<String> list = bot.getPlaylistLoader().getPlaylistNames();
if(list==null)
{
event.reply(event.getClient().getError()+" Failed to load available playlists!");
CommandLogContext.setError("playlist_list_failed");
}
else if(list.isEmpty())
{
event.reply(event.getClient().getWarning()+" There are no playlists in the Playlists folder!");
CommandLogContext.setMeta(new JSONObject().put("count", 0));
}
else
{
StringBuilder builder = new StringBuilder(event.getClient().getSuccess()+" Available playlists:\n");
list.forEach(str -> builder.append("`").append(str).append("` "));
event.reply(builder.toString());
CommandLogContext.setMeta(new JSONObject().put("count", list.size()));
}
}
}
@@ -20,8 +20,10 @@ import java.io.InputStream;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import com.jagrosh.jmusicbot.utils.OtherUtil;
import net.dv8tion.jda.api.entities.Icon;
import org.json.JSONObject;
/**
*
@@ -31,6 +33,7 @@ public class SetavatarCmd extends OwnerCommand
{
public SetavatarCmd(Bot bot)
{
super(bot);
this.name = "setavatar";
this.help = "sets the avatar of the bot";
this.arguments = "<url>";
@@ -39,8 +42,9 @@ public class SetavatarCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
CommandLogContext.setManualLogging();
String url;
if(event.getArgs().isEmpty())
if(!event.getMessage().getAttachments().isEmpty() && event.getMessage().getAttachments().get(0).isImage())
@@ -53,16 +57,33 @@ public class SetavatarCmd extends OwnerCommand
if(s==null)
{
event.reply(event.getClient().getError()+" Invalid or missing URL");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "invalid_avatar_url"));
}
else
{
try {
event.getSelfUser().getManager().setAvatar(Icon.from(s)).queue(
v -> event.reply(event.getClient().getSuccess()+" Successfully changed avatar."),
t -> event.reply(event.getClient().getError()+" Failed to set avatar."));
v -> {
event.reply(event.getClient().getSuccess()+" Successfully changed avatar.");
logCommandEvent(event, "OK", new JSONObject()
.put("source", event.getArgs().isEmpty() ? "attachment" : "url"));
},
t -> {
event.reply(event.getClient().getError()+" Failed to set avatar.");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "avatar_set_failed"));
});
} catch(IOException e) {
event.reply(event.getClient().getError()+" Could not load from provided URL.");
logCommandEvent(event, "ERROR", new JSONObject().put("error_reason", "avatar_load_failed"));
}
}
}
private void logCommandEvent(CommandEvent event, String result, JSONObject meta)
{
if(bot.getDataLogService() == null || event == null || event.getGuild() == null)
return;
bot.getDataLogService().logCommandEvent(event.getGuild(), event.getAuthor(), getName(),
event.getArgs(), result, meta == null ? null : meta.toString());
}
}
@@ -18,7 +18,9 @@ package com.jagrosh.jmusicbot.commands.owner;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import net.dv8tion.jda.api.entities.Activity;
import org.json.JSONObject;
/**
*
@@ -28,6 +30,7 @@ public class SetgameCmd extends OwnerCommand
{
public SetgameCmd(Bot bot)
{
super(bot);
this.name = "setgame";
this.help = "sets the game the bot is playing";
this.arguments = "[action] [game]";
@@ -41,7 +44,7 @@ public class SetgameCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
String title = event.getArgs().toLowerCase().startsWith("playing") ? event.getArgs().substring(7).trim() : event.getArgs();
try
@@ -49,10 +52,14 @@ public class SetgameCmd extends OwnerCommand
event.getJDA().getPresence().setActivity(title.isEmpty() ? null : Activity.playing(title));
event.reply(event.getClient().getSuccess()+" **"+event.getSelfUser().getName()
+"** is "+(title.isEmpty() ? "no longer playing anything." : "now playing `"+title+"`"));
CommandLogContext.setMeta(new JSONObject()
.put("activity_type", "PLAYING")
.put("title", title.isEmpty() ? JSONObject.NULL : title));
}
catch(Exception e)
{
event.reply(event.getClient().getError()+" The game could not be set!");
CommandLogContext.setError("set_game_failed");
}
}
@@ -60,6 +67,7 @@ public class SetgameCmd extends OwnerCommand
{
private SetstreamCmd()
{
super(SetgameCmd.this.bot);
this.name = "stream";
this.aliases = new String[]{"twitch","streaming"};
this.help = "sets the game the bot is playing to a stream";
@@ -68,12 +76,13 @@ public class SetgameCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
String[] parts = event.getArgs().split("\\s+", 2);
if(parts.length<2)
{
event.replyError("Please include a twitch username and the name of the game to 'stream'");
CommandLogContext.setError("missing_stream_args");
return;
}
try
@@ -81,10 +90,15 @@ public class SetgameCmd extends OwnerCommand
event.getJDA().getPresence().setActivity(Activity.streaming(parts[1], "https://twitch.tv/"+parts[0]));
event.replySuccess("**"+event.getSelfUser().getName()
+"** is now streaming `"+parts[1]+"`");
CommandLogContext.setMeta(new JSONObject()
.put("activity_type", "STREAMING")
.put("title", parts[1])
.put("user", parts[0]));
}
catch(Exception e)
{
event.reply(event.getClient().getError()+" The game could not be set!");
CommandLogContext.setError("set_game_failed");
}
}
}
@@ -93,6 +107,7 @@ public class SetgameCmd extends OwnerCommand
{
private SetlistenCmd()
{
super(SetgameCmd.this.bot);
this.name = "listen";
this.aliases = new String[]{"listening"};
this.help = "sets the game the bot is listening to";
@@ -101,11 +116,12 @@ public class SetgameCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(event.getArgs().isEmpty())
{
event.replyError("Please include a title to listen to!");
CommandLogContext.setError("missing_listen_title");
return;
}
String title = event.getArgs().toLowerCase().startsWith("to") ? event.getArgs().substring(2).trim() : event.getArgs();
@@ -113,8 +129,10 @@ public class SetgameCmd extends OwnerCommand
{
event.getJDA().getPresence().setActivity(Activity.listening(title));
event.replySuccess("**"+event.getSelfUser().getName()+"** is now listening to `"+title+"`");
CommandLogContext.setMeta(new JSONObject().put("activity_type", "LISTENING").put("title", title));
} catch(Exception e) {
event.reply(event.getClient().getError()+" The game could not be set!");
CommandLogContext.setError("set_game_failed");
}
}
}
@@ -123,6 +141,7 @@ public class SetgameCmd extends OwnerCommand
{
private SetwatchCmd()
{
super(SetgameCmd.this.bot);
this.name = "watch";
this.aliases = new String[]{"watching"};
this.help = "sets the game the bot is watching";
@@ -131,11 +150,12 @@ public class SetgameCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
if(event.getArgs().isEmpty())
{
event.replyError("Please include a title to watch!");
CommandLogContext.setError("missing_watch_title");
return;
}
String title = event.getArgs();
@@ -143,8 +163,10 @@ public class SetgameCmd extends OwnerCommand
{
event.getJDA().getPresence().setActivity(Activity.watching(title));
event.replySuccess("**"+event.getSelfUser().getName()+"** is now watching `"+title+"`");
CommandLogContext.setMeta(new JSONObject().put("activity_type", "WATCHING").put("title", title));
} catch(Exception e) {
event.reply(event.getClient().getError()+" The game could not be set!");
CommandLogContext.setError("set_game_failed");
}
}
}
@@ -18,7 +18,9 @@ package com.jagrosh.jmusicbot.commands.owner;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import net.dv8tion.jda.api.exceptions.RateLimitedException;
import org.json.JSONObject;
/**
*
@@ -28,6 +30,7 @@ public class SetnameCmd extends OwnerCommand
{
public SetnameCmd(Bot bot)
{
super(bot);
this.name = "setname";
this.help = "sets the name of the bot";
this.arguments = "<name>";
@@ -36,21 +39,24 @@ public class SetnameCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
try
{
String oldname = event.getSelfUser().getName();
event.getSelfUser().getManager().setName(event.getArgs()).complete(false);
event.reply(event.getClient().getSuccess()+" Name changed from `"+oldname+"` to `"+event.getArgs()+"`");
CommandLogContext.setMeta(new JSONObject().put("before", oldname).put("after", event.getArgs()));
}
catch(RateLimitedException e)
{
event.reply(event.getClient().getError()+" Name can only be changed twice per hour!");
CommandLogContext.setError("rate_limited");
}
catch(Exception e)
{
event.reply(event.getClient().getError()+" That name is not valid!");
CommandLogContext.setError("invalid_name");
}
}
}
@@ -18,7 +18,9 @@ package com.jagrosh.jmusicbot.commands.owner;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import net.dv8tion.jda.api.OnlineStatus;
import org.json.JSONObject;
/**
*
@@ -28,6 +30,7 @@ public class SetstatusCmd extends OwnerCommand
{
public SetstatusCmd(Bot bot)
{
super(bot);
this.name = "setstatus";
this.help = "sets the status the bot displays";
this.arguments = "<status>";
@@ -36,21 +39,27 @@ public class SetstatusCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
try {
OnlineStatus status = OnlineStatus.fromKey(event.getArgs());
if(status==OnlineStatus.UNKNOWN)
{
event.replyError("Please include one of the following statuses: `ONLINE`, `IDLE`, `DND`, `INVISIBLE`");
CommandLogContext.setError("invalid_status");
}
else
{
OnlineStatus before = event.getJDA().getPresence().getStatus();
event.getJDA().getPresence().setStatus(status);
event.replySuccess("Set the status to `"+status.getKey().toUpperCase()+"`");
CommandLogContext.setMeta(new JSONObject()
.put("before", before == null ? JSONObject.NULL : before.getKey())
.put("after", status.getKey()));
}
} catch(Exception e) {
event.reply(event.getClient().getError()+" The status could not be set!");
CommandLogContext.setError("set_status_failed");
}
}
}
@@ -18,6 +18,8 @@ package com.jagrosh.jmusicbot.commands.owner;
import com.jagrosh.jdautilities.command.CommandEvent;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.commands.OwnerCommand;
import com.jagrosh.jmusicbot.datalog.CommandLogContext;
import org.json.JSONObject;
/**
*
@@ -25,11 +27,9 @@ import com.jagrosh.jmusicbot.commands.OwnerCommand;
*/
public class ShutdownCmd extends OwnerCommand
{
private final Bot bot;
public ShutdownCmd(Bot bot)
{
this.bot = bot;
super(bot);
this.name = "shutdown";
this.help = "safely shuts down";
this.aliases = bot.getConfig().getAliases(this.name);
@@ -37,8 +37,9 @@ public class ShutdownCmd extends OwnerCommand
}
@Override
protected void execute(CommandEvent event)
public void doCommand(CommandEvent event)
{
CommandLogContext.setMeta(new JSONObject().put("invoked", true));
event.replyWarning("Shutting down...");
bot.shutdown();
}
@@ -0,0 +1,84 @@
/*
* Copyright 2026
*
* 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.
*/
package com.jagrosh.jmusicbot.datalog;
import org.json.JSONObject;
public final class CommandLogContext
{
private static final ThreadLocal<CommandLogEntry> CONTEXT = new ThreadLocal<>();
private CommandLogContext()
{
}
public static void clear()
{
CONTEXT.remove();
}
public static void setMeta(JSONObject meta)
{
getOrCreate().meta = meta;
}
public static void setResult(String result)
{
getOrCreate().result = result;
}
public static void setError(String reason)
{
CommandLogEntry entry = getOrCreate();
entry.result = "ERROR";
if(reason != null && !reason.isEmpty())
{
if(entry.meta == null)
entry.meta = new JSONObject();
entry.meta.put("error_reason", reason);
}
}
public static void setManualLogging()
{
getOrCreate().manual = true;
}
public static CommandLogEntry consume()
{
CommandLogEntry entry = CONTEXT.get();
CONTEXT.remove();
return entry;
}
private static CommandLogEntry getOrCreate()
{
CommandLogEntry entry = CONTEXT.get();
if(entry == null)
{
entry = new CommandLogEntry();
CONTEXT.set(entry);
}
return entry;
}
public static final class CommandLogEntry
{
public JSONObject meta;
public String result;
public boolean manual;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,17 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.datalog;
public final class StatsAccess
{
private StatsAccess() {}
public static boolean canViewUserStats(long requesterId, long targetId, boolean canManageServer)
{
return requesterId == targetId || canManageServer;
}
}
@@ -0,0 +1,26 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.datalog;
public final class StatsFilters
{
private StatsFilters() {}
public static String normalizeSource(String source)
{
if(source == null)
return null;
String normalized = source.trim().toLowerCase();
if(normalized.isEmpty() || "all".equals(normalized) || "any".equals(normalized))
return null;
if("yt".equals(normalized) || "youtube.com".equals(normalized) || "youtu.be".equals(normalized))
return "youtube";
if("sc".equals(normalized) || "soundcloud.com".equals(normalized))
return "soundcloud";
return normalized;
}
}
@@ -0,0 +1,175 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.datalog;
import com.jagrosh.jmusicbot.utils.TimeUtil;
import net.dv8tion.jda.api.EmbedBuilder;
public final class StatsMessageFormatter
{
private static final int EMBED_FIELD_VALUE_LIMIT = 1024;
private static final int SAFE_FIELD_VALUE_LIMIT = 1000;
private StatsMessageFormatter() {}
public static EmbedBuilder summaryEmbed(String title, StatsSummary summary, int color)
{
EmbedBuilder eb = new EmbedBuilder()
.setTitle(title)
.setColor(color)
.setDescription("Range: **" + summary.getRange().getLabel() + "**"
+ (summary.getSource() == null ? "" : "\nSource: **" + summary.getSource() + "**")
+ "\nPlays: **" + summary.getTotalPlays() + "**"
+ "\nListening time: **" + TimeUtil.formatTime(summary.getTotalListeningMillis()) + "**");
addCountField(eb, "Top tracks", summary.getTopTracks(), "plays");
addTimeField(eb, "Top listeners", summary.getTopListeners());
addCountField(eb, "Top requesters", summary.getTopRequesters(), "plays");
addCountField(eb, "Top sources", summary.getTopSources(), "plays");
addCountField(eb, "Skip actions", summary.getSkipActions(), "skips");
addCountField(eb, "Skip votes", summary.getSkipVotes(), "votes");
return eb;
}
public static EmbedBuilder personalEmbed(String title, StatsSummary summary, int color)
{
long skipActions = sumCounts(summary.getSkipActions());
long skipVotes = sumCounts(summary.getSkipVotes());
EmbedBuilder eb = new EmbedBuilder()
.setTitle(title)
.setColor(color)
.setDescription("Range: **" + summary.getRange().getLabel() + "**"
+ (summary.getSource() == null ? "" : "\nSource: **" + summary.getSource() + "**")
+ "\nRequests: **" + summary.getTotalPlays() + "**"
+ "\nListening time: **" + TimeUtil.formatTime(summary.getTotalListeningMillis()) + "**"
+ "\nSkip actions: **" + skipActions + "**"
+ "\nSkip votes: **" + skipVotes + "**");
addCountField(eb, "Top tracks", summary.getTopTracks(), "plays");
addCountField(eb, "Top sources", summary.getTopSources(), "plays");
return eb;
}
public static String countList(Iterable<StatsRow> rows, String unit)
{
return countList(rows, unit, 5);
}
public static String countList(Iterable<StatsRow> rows, String unit, int limit)
{
StringBuilder builder = new StringBuilder();
int index = 1;
for(StatsRow row : rows)
{
if(index > limit)
break;
builder.append(index++).append(". **").append(row.getLabel()).append("** - ")
.append(row.getCount()).append(" ").append(unit);
if(row.getDetail() != null && !row.getDetail().isEmpty())
builder.append(" (").append(row.getDetail()).append(")");
builder.append("\n");
}
return builder.length() == 0 ? "No data" : builder.toString();
}
public static String timeList(Iterable<StatsRow> rows)
{
return timeList(rows, 5);
}
public static String timeList(Iterable<StatsRow> rows, int limit)
{
StringBuilder builder = new StringBuilder();
int index = 1;
for(StatsRow row : rows)
{
if(index > limit)
break;
builder.append(index++).append(". **").append(row.getLabel()).append("** - ")
.append(TimeUtil.formatTime(row.getMillis())).append("\n");
}
return builder.length() == 0 ? "No data" : builder.toString();
}
public static String trackListenList(Iterable<StatsRow> rows, int limit)
{
StringBuilder builder = new StringBuilder();
int index = 1;
for(StatsRow row : rows)
{
if(index > limit)
break;
builder.append(index++).append(". **").append(row.getLabel()).append("** - ")
.append(TimeUtil.formatTime(row.getMillis()));
if(row.getCount() > 0L)
builder.append(" (").append(row.getCount()).append(" listen").append(row.getCount() == 1L ? "" : "s").append(")");
if(row.getDetail() != null && !row.getDetail().isEmpty())
builder.append(" (").append(row.getDetail()).append(")");
builder.append("\n");
}
return builder.length() == 0 ? "No data" : builder.toString();
}
private static void addCountField(EmbedBuilder eb, String name, Iterable<StatsRow> rows, String unit)
{
addChunkedField(eb, name, countList(rows, unit), false);
}
private static void addTimeField(EmbedBuilder eb, String name, Iterable<StatsRow> rows)
{
addChunkedField(eb, name, timeList(rows), false);
}
public static void addChunkedField(EmbedBuilder eb, String name, String value, boolean inline)
{
if(value == null || value.isEmpty())
{
eb.addField(name, "No data", inline);
return;
}
String[] lines = value.split("\\n");
StringBuilder chunk = new StringBuilder();
int part = 1;
for(String rawLine : lines)
{
if(rawLine == null || rawLine.isEmpty())
continue;
String line = fitFieldLine(rawLine);
int nextLength = chunk.length() + line.length() + 1;
if(chunk.length() > 0 && nextLength > SAFE_FIELD_VALUE_LIMIT)
{
eb.addField(partName(name, part++), chunk.toString(), inline);
chunk.setLength(0);
}
if(chunk.length() > 0)
chunk.append("\n");
chunk.append(line);
}
if(chunk.length() == 0)
chunk.append("No data");
eb.addField(partName(name, part), chunk.toString(), inline);
}
private static String partName(String name, int part)
{
return part <= 1 ? name : name + " " + part;
}
private static String fitFieldLine(String line)
{
if(line.length() <= EMBED_FIELD_VALUE_LIMIT)
return line;
return line.substring(0, SAFE_FIELD_VALUE_LIMIT - 3) + "...";
}
private static long sumCounts(Iterable<StatsRow> rows)
{
long count = 0L;
for(StatsRow row : rows)
count += row.getCount();
return count;
}
}
@@ -0,0 +1,84 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.datalog;
import com.jagrosh.jmusicbot.Bot;
import com.jagrosh.jmusicbot.settings.Settings;
import com.jagrosh.jmusicbot.settings.StatsReportFrequency;
import java.time.LocalDate;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class StatsReportScheduler
{
private static final Logger LOG = LoggerFactory.getLogger("StatsReports");
private final Bot bot;
private final Map<Long, String> sentPeriods = new ConcurrentHashMap<>();
public StatsReportScheduler(Bot bot)
{
this.bot = bot;
}
public void init()
{
bot.getThreadpool().scheduleWithFixedDelay(() -> runScheduledReports(), 1, 1, TimeUnit.HOURS);
}
private void runScheduledReports()
{
if(bot.getDataLogService() == null)
return;
JDA jda = bot.getJDA();
if(jda == null)
return;
LocalDate today = LocalDate.now();
for(Guild guild : jda.getGuilds())
{
Settings settings = bot.getSettingsManager().getSettings(guild);
if(settings.getStatsReportFrequency() == StatsReportFrequency.OFF)
continue;
if(settings.getStatsReportFrequency() == StatsReportFrequency.MONTHLY && today.getDayOfMonth() != 1)
continue;
if(settings.getStatsReportFrequency() == StatsReportFrequency.YEARLY && (today.getDayOfMonth() != 1 || today.getMonthValue() != 1))
continue;
TextChannel channel = settings.getStatsReportChannel(guild);
if(channel == null)
continue;
StatsTimeRange range = settings.getStatsReportFrequency() == StatsReportFrequency.YEARLY
? StatsTimeRange.previousYear()
: StatsTimeRange.previousMonth();
String periodKey = settings.getStatsReportFrequency().name() + ":" + range.getLabel();
if(periodKey.equals(sentPeriods.get(guild.getIdLong())))
continue;
if(sendReport(guild, channel, range))
sentPeriods.put(guild.getIdLong(), periodKey);
}
}
public boolean sendReport(Guild guild, TextChannel channel, StatsTimeRange range)
{
if(bot.getDataLogService() == null || guild == null || channel == null)
return false;
StatsSummary summary = bot.getDataLogService().getStatsSummary(guild.getIdLong(), range, null, null);
if(summary.isEmpty())
return false;
channel.sendMessageEmbeds(StatsMessageFormatter.summaryEmbed("Server rewind - " + range.getLabel(), summary,
guild.getSelfMember().getColorRaw()).build()).queue(
m -> {},
t -> LOG.debug("Failed to send stats report for guild {}: {}", guild.getId(), t.getMessage()));
return true;
}
}
@@ -0,0 +1,50 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.datalog;
public class StatsRow
{
private final long id;
private final String label;
private final String detail;
private final long count;
private final long millis;
public StatsRow(long id, String label, String detail, long count, long millis)
{
this.id = id;
this.label = label;
this.detail = detail;
this.count = count;
this.millis = millis;
}
public long getId()
{
return id;
}
public String getLabel()
{
return label;
}
public String getDetail()
{
return detail;
}
public long getCount()
{
return count;
}
public long getMillis()
{
return millis;
}
}
@@ -0,0 +1,97 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.datalog;
import java.util.Collections;
import java.util.List;
public class StatsSummary
{
private final StatsTimeRange range;
private final String source;
private final long totalPlays;
private final long totalListeningMillis;
private final List<StatsRow> topTracks;
private final List<StatsRow> topRequesters;
private final List<StatsRow> topListeners;
private final List<StatsRow> topSources;
private final List<StatsRow> skipVotes;
private final List<StatsRow> skipActions;
public StatsSummary(StatsTimeRange range, String source, long totalPlays, long totalListeningMillis,
List<StatsRow> topTracks, List<StatsRow> topRequesters, List<StatsRow> topListeners,
List<StatsRow> topSources, List<StatsRow> skipVotes, List<StatsRow> skipActions)
{
this.range = range;
this.source = source;
this.totalPlays = totalPlays;
this.totalListeningMillis = totalListeningMillis;
this.topTracks = topTracks == null ? Collections.emptyList() : topTracks;
this.topRequesters = topRequesters == null ? Collections.emptyList() : topRequesters;
this.topListeners = topListeners == null ? Collections.emptyList() : topListeners;
this.topSources = topSources == null ? Collections.emptyList() : topSources;
this.skipVotes = skipVotes == null ? Collections.emptyList() : skipVotes;
this.skipActions = skipActions == null ? Collections.emptyList() : skipActions;
}
public StatsTimeRange getRange()
{
return range;
}
public String getSource()
{
return source;
}
public long getTotalPlays()
{
return totalPlays;
}
public long getTotalListeningMillis()
{
return totalListeningMillis;
}
public List<StatsRow> getTopTracks()
{
return topTracks;
}
public List<StatsRow> getTopRequesters()
{
return topRequesters;
}
public List<StatsRow> getTopListeners()
{
return topListeners;
}
public List<StatsRow> getTopSources()
{
return topSources;
}
public List<StatsRow> getSkipVotes()
{
return skipVotes;
}
public List<StatsRow> getSkipActions()
{
return skipActions;
}
public boolean isEmpty()
{
return totalPlays == 0L && totalListeningMillis == 0L
&& topTracks.isEmpty() && topRequesters.isEmpty() && topListeners.isEmpty()
&& topSources.isEmpty() && skipVotes.isEmpty() && skipActions.isEmpty();
}
}
@@ -0,0 +1,143 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.datalog;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.Month;
import java.time.ZoneId;
import java.time.ZonedDateTime;
public class StatsTimeRange
{
private final String key;
private final String label;
private final Long startMillis;
private final Long endMillis;
private StatsTimeRange(String key, String label, Long startMillis, Long endMillis)
{
this.key = key;
this.label = label;
this.startMillis = startMillis;
this.endMillis = endMillis;
}
public static StatsTimeRange all()
{
return new StatsTimeRange("all", "all time", null, null);
}
public static StatsTimeRange parse(String token)
{
if(token == null || token.trim().isEmpty())
return all();
String normalized = token.trim().toLowerCase();
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime start;
if(normalized.matches("\\d+[dwmy]"))
{
int amount = Integer.parseInt(normalized.substring(0, normalized.length() - 1));
char unit = normalized.charAt(normalized.length() - 1);
switch(unit)
{
case 'd':
start = now.minusDays(amount);
return new StatsTimeRange(normalized, "last " + amount + " day" + (amount == 1 ? "" : "s"), start.toInstant().toEpochMilli(), null);
case 'w':
start = now.minusWeeks(amount);
return new StatsTimeRange(normalized, "last " + amount + " week" + (amount == 1 ? "" : "s"), start.toInstant().toEpochMilli(), null);
case 'm':
start = now.minusMonths(amount);
return new StatsTimeRange(normalized, "last " + amount + " month" + (amount == 1 ? "" : "s"), start.toInstant().toEpochMilli(), null);
case 'y':
start = now.minusYears(amount);
return new StatsTimeRange(normalized, "last " + amount + " year" + (amount == 1 ? "" : "s"), start.toInstant().toEpochMilli(), null);
default:
break;
}
}
switch(normalized)
{
case "today":
case "day":
start = now.toLocalDate().atStartOfDay(now.getZone());
return new StatsTimeRange("today", "today", start.toInstant().toEpochMilli(), null);
case "week":
case "thisweek":
LocalDate monday = now.toLocalDate().with(DayOfWeek.MONDAY);
start = monday.atStartOfDay(now.getZone());
return new StatsTimeRange("week", "this week", start.toInstant().toEpochMilli(), null);
case "month":
case "thismonth":
start = now.withDayOfMonth(1).toLocalDate().atStartOfDay(now.getZone());
return new StatsTimeRange("month", "this month", start.toInstant().toEpochMilli(), null);
case "year":
case "thisyear":
start = now.withDayOfYear(1).toLocalDate().atStartOfDay(now.getZone());
return new StatsTimeRange("year", "this year", start.toInstant().toEpochMilli(), null);
case "all":
case "alltime":
return all();
default:
return null;
}
}
public static StatsTimeRange previousMonth()
{
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime start = now.minusMonths(1).withDayOfMonth(1).toLocalDate().atStartOfDay(now.getZone());
ZonedDateTime end = start.plusMonths(1);
return new StatsTimeRange("month", start.getMonth() + " " + start.getYear(), start.toInstant().toEpochMilli(), end.toInstant().toEpochMilli());
}
public static StatsTimeRange month(int year, int month)
{
if(month < 1 || month > 12)
return null;
ZonedDateTime start = ZonedDateTime.of(year, month, 1, 0, 0, 0, 0, ZoneId.systemDefault());
ZonedDateTime end = start.plusMonths(1);
return new StatsTimeRange(String.format("%04d-%02d", year, month), Month.of(month) + " " + year,
start.toInstant().toEpochMilli(), end.toInstant().toEpochMilli());
}
public static StatsTimeRange previousYear()
{
ZonedDateTime now = ZonedDateTime.now();
ZonedDateTime start = ZonedDateTime.of(now.getYear() - 1, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault());
ZonedDateTime end = start.plusYears(1);
return new StatsTimeRange("year", Integer.toString(start.getYear()), start.toInstant().toEpochMilli(), end.toInstant().toEpochMilli());
}
public static StatsTimeRange year(int year)
{
ZonedDateTime start = ZonedDateTime.of(year, 1, 1, 0, 0, 0, 0, ZoneId.systemDefault());
ZonedDateTime end = start.plusYears(1);
return new StatsTimeRange(Integer.toString(year), Integer.toString(year), start.toInstant().toEpochMilli(), end.toInstant().toEpochMilli());
}
public String getKey()
{
return key;
}
public String getLabel()
{
return label;
}
public Long getStartMillis()
{
return startMillis;
}
public Long getEndMillis()
{
return endMillis;
}
}
@@ -39,8 +39,15 @@ public class Settings implements GuildSettingsProvider
private QueueType queueType;
private String prefix;
private double skipRatio;
private long statsReportChannelId;
private StatsReportFrequency statsReportFrequency;
public Settings(SettingsManager manager, String textId, String voiceId, String roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType)
{
this(manager, textId, voiceId, roleId, volume, defaultPlaylist, repeatMode, prefix, skipRatio, queueType, null, StatsReportFrequency.OFF);
}
public Settings(SettingsManager manager, String textId, String voiceId, String roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType, String statsReportChannelId, StatsReportFrequency statsReportFrequency)
{
this.manager = manager;
try
@@ -73,9 +80,23 @@ public class Settings implements GuildSettingsProvider
this.prefix = prefix;
this.skipRatio = skipRatio;
this.queueType = queueType;
try
{
this.statsReportChannelId = Long.parseLong(statsReportChannelId);
}
catch(NumberFormatException e)
{
this.statsReportChannelId = 0L;
}
this.statsReportFrequency = statsReportFrequency == null ? StatsReportFrequency.OFF : statsReportFrequency;
}
public Settings(SettingsManager manager, long textId, long voiceId, long roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType)
{
this(manager, textId, voiceId, roleId, volume, defaultPlaylist, repeatMode, prefix, skipRatio, queueType, 0L, StatsReportFrequency.OFF);
}
public Settings(SettingsManager manager, long textId, long voiceId, long roleId, int volume, String defaultPlaylist, RepeatMode repeatMode, String prefix, double skipRatio, QueueType queueType, long statsReportChannelId, StatsReportFrequency statsReportFrequency)
{
this.manager = manager;
this.textId = textId;
@@ -87,6 +108,8 @@ public class Settings implements GuildSettingsProvider
this.prefix = prefix;
this.skipRatio = skipRatio;
this.queueType = queueType;
this.statsReportChannelId = statsReportChannelId;
this.statsReportFrequency = statsReportFrequency == null ? StatsReportFrequency.OFF : statsReportFrequency;
}
// Getters
@@ -135,6 +158,21 @@ public class Settings implements GuildSettingsProvider
return queueType;
}
public TextChannel getStatsReportChannel(Guild guild)
{
return guild == null ? null : guild.getTextChannelById(statsReportChannelId);
}
public long getStatsReportChannelId()
{
return statsReportChannelId;
}
public StatsReportFrequency getStatsReportFrequency()
{
return statsReportFrequency;
}
@Override
public Collection<String> getPrefixes()
{
@@ -195,4 +233,16 @@ public class Settings implements GuildSettingsProvider
this.queueType = queueType;
this.manager.writeSettings();
}
public void setStatsReportChannel(TextChannel channel)
{
this.statsReportChannelId = channel == null ? 0L : channel.getIdLong();
this.manager.writeSettings();
}
public void setStatsReportFrequency(StatsReportFrequency frequency)
{
this.statsReportFrequency = frequency == null ? StatsReportFrequency.OFF : frequency;
this.manager.writeSettings();
}
}
@@ -60,7 +60,9 @@ public class SettingsManager implements GuildSettingsManager<Settings>
o.has("repeat_mode") ? o.getEnum(RepeatMode.class, "repeat_mode"): RepeatMode.OFF,
o.has("prefix") ? o.getString("prefix") : null,
o.has("skip_ratio") ? o.getDouble("skip_ratio") : -1,
o.has("queue_type") ? o.getEnum(QueueType.class, "queue_type") : QueueType.FAIR));
o.has("queue_type") ? o.getEnum(QueueType.class, "queue_type") : QueueType.FAIR,
o.has("stats_report_channel_id") ? o.getString("stats_report_channel_id") : null,
o.has("stats_report_frequency") ? StatsReportFrequency.parse(o.getString("stats_report_frequency")) : StatsReportFrequency.OFF));
});
} catch (NoSuchFileException e) {
// create an empty json file
@@ -97,7 +99,7 @@ public class SettingsManager implements GuildSettingsManager<Settings>
private Settings createDefaultSettings()
{
return new Settings(this, 0, 0, 0, 100, null, RepeatMode.OFF, null, -1, QueueType.FAIR);
return new Settings(this, 0, 0, 0, 100, null, RepeatMode.OFF, null, -1, QueueType.FAIR, 0L, StatsReportFrequency.OFF);
}
protected void writeSettings()
@@ -124,6 +126,10 @@ public class SettingsManager implements GuildSettingsManager<Settings>
o.put("skip_ratio", s.getSkipRatio());
if(s.getQueueType() != QueueType.FAIR)
o.put("queue_type", s.getQueueType().name());
if(s.getStatsReportChannelId() != 0L)
o.put("stats_report_channel_id", Long.toString(s.getStatsReportChannelId()));
if(s.getStatsReportFrequency() != StatsReportFrequency.OFF)
o.put("stats_report_frequency", s.getStatsReportFrequency().name());
obj.put(Long.toString(key), o);
});
try {
@@ -0,0 +1,24 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot.settings;
public enum StatsReportFrequency
{
OFF,
MONTHLY,
YEARLY;
public static StatsReportFrequency parse(String value)
{
if(value == null)
return OFF;
for(StatsReportFrequency frequency : values())
if(frequency.name().equalsIgnoreCase(value))
return frequency;
return OFF;
}
}
@@ -15,6 +15,7 @@
*/
package com.jagrosh.jmusicbot.utils;
import com.jagrosh.jdautilities.commons.JDAUtilitiesInfo;
import com.jagrosh.jmusicbot.JMusicBot;
import com.jagrosh.jmusicbot.entities.Prompt;
import java.io.*;
@@ -23,6 +24,7 @@ import java.net.URL;
import java.net.URLConnection;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.Properties;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.OnlineStatus;
@@ -186,6 +188,26 @@ public class OtherUtil
else
return "UNKNOWN";
}
public static String getCommandLibraryVersion()
{
try(InputStream input = OtherUtil.class.getResourceAsStream("/com/jagrosh/jmusicbot/versions.properties"))
{
if(input != null)
{
Properties properties = new Properties();
properties.load(input);
String version = properties.getProperty("jda-chewtils.version");
if(version != null && !version.trim().isEmpty())
return version.trim();
}
}
catch(IOException ignored) {}
if(JDAUtilitiesInfo.VERSION_REVISION != null && !JDAUtilitiesInfo.VERSION_REVISION.startsWith("@"))
return JDAUtilitiesInfo.VERSION;
return JDAUtilitiesInfo.VERSION_MAJOR + "." + JDAUtilitiesInfo.VERSION_MINOR;
}
public static String getLatestVersion()
{
@@ -4,28 +4,41 @@ import com.sedmelluq.lava.extensions.youtuberotator.planner.*;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.IpBlock;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv4Block;
import com.sedmelluq.lava.extensions.youtuberotator.tools.ip.Ipv6Block;
import com.sun.net.httpserver.HttpServer;
import dev.lavalink.youtube.clients.Web;
import dev.lavalink.youtube.clients.WebEmbedded;
import org.json.JSONObject;
import org.openqa.selenium.By;
import org.openqa.selenium.Cookie;
import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.WebDriver;
import org.openqa.selenium.WebElement;
import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeDriverService;
import org.openqa.selenium.chrome.ChromeOptions;
import org.openqa.selenium.chromium.ChromiumDriverLogLevel;
import org.openqa.selenium.devtools.NetworkInterceptor;
import org.openqa.selenium.logging.LogEntries;
import org.openqa.selenium.logging.LogEntry;
import org.openqa.selenium.logging.LogType;
import org.openqa.selenium.logging.LoggingPreferences;
import org.openqa.selenium.os.ExecutableFinder;
import org.openqa.selenium.remote.http.Contents;
import org.openqa.selenium.remote.http.Filter;
import org.openqa.selenium.remote.http.HttpMethod;
import org.openqa.selenium.support.ui.ExpectedConditions;
import org.openqa.selenium.support.ui.WebDriverWait;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.zip.GZIPInputStream;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.logging.Level;
import io.github.bonigarcia.wdm.WebDriverManager;
@@ -105,6 +118,21 @@ public class YouTubeUtil {
chromeOptions.setBinary(userChromePath != null ? userChromePath : wdm.getBrowserPath().get().toString());
// Essential flags for headless/containerized environments (Docker)
chromeOptions.addArguments("--no-sandbox");
chromeOptions.addArguments("--disable-dev-shm-usage");
chromeOptions.addArguments("--disable-gpu");
chromeOptions.addArguments("--window-size=1920,1080");
chromeOptions.addArguments("--disable-blink-features=AutomationControlled");
chromeOptions.addArguments("--autoplay-policy=no-user-gesture-required");
chromeOptions.addArguments("--disable-features=IsolateOrigins,site-per-process");
chromeOptions.addArguments("--disable-site-isolation-trials");
// Enable Chrome performance logging to capture Network.requestWillBeSent events
LoggingPreferences logPrefs = new LoggingPreferences();
logPrefs.enable(LogType.PERFORMANCE, Level.ALL);
chromeOptions.setCapability("goog:loggingPrefs", logPrefs);
if (!headless)
chromeOptions.addArguments("--auto-open-devtools-for-tabs");
else
@@ -117,77 +145,274 @@ public class YouTubeUtil {
WebDriver driver = new ChromeDriver(chromeDriverBuilder.build(), chromeOptions);
//
// 2. Setup a network interceptor to intercept the player API request & obtain po token and visitor data
//
// as the network interceptor below runs on a different thread, we'll have it notify us on this/current thread
// whether retrieving the data failed (completes with the Exception) or succeeded (completes with null)
CompletableFuture<Exception> interceptorFuture = new CompletableFuture<>();
NetworkInterceptor interceptor = new NetworkInterceptor(driver,
(Filter) next -> req -> {
if (req.getMethod() != HttpMethod.POST || !req.getUri().contains("/youtubei/v1/player"))
return next.execute(req);
try {
String rawBody = Contents.string(req);
JSONObject body = new JSONObject(rawBody);
String visitorData = body
.getJSONObject("context")
.getJSONObject("client")
.getString("visitorData");
String poToken = body
.getJSONObject("serviceIntegrityDimensions")
.getString("poToken");
LOGGER.info("Successfully retrieved PO token & visitor data for YouTube playback.");
LOGGER.debug("PO token: {}", poToken);
LOGGER.debug("Visitor Data: {}", visitorData);
if (poToken.length() < 160)
LOGGER.warn("There is a high chance that the retrieved PO token will not work. Try on a different network.");
Web.setPoTokenAndVisitorData(poToken, visitorData);
interceptorFuture.complete(null);
} catch (Exception e) {
interceptorFuture.complete(e);
}
return next.execute(req);
});
//
// 3. Navigate to YouTube & start the player
// 2. Start local HTTP server to host iframe embed & extract PO token from performance logs
//
HttpServer server = null;
try {
driver.get("https://www.youtube.com/embed/aqz-KE-bpKQ");
driver.manage().timeouts().implicitlyWait(Duration.ofSeconds(15));
WebElement moviePlayer = driver.findElement(By.cssSelector("#movie_player"));
moviePlayer.click();
// Start a lightweight local HTTP server to host the YouTube embed in an iframe.
// This provides a valid origin/Referer (http://127.0.0.1:PORT) so YouTube does not
// reject the embed with Error 153 "Video player configuration error".
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/embed", exchange -> {
String html = "<!DOCTYPE html>" +
"<html>" +
"<head>" +
"<meta name=\"referrer\" content=\"strict-origin-when-cross-origin\">" +
"<style>html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000;}" +
"iframe{width:100%;height:100%;border:none;}</style>" +
"</head>" +
"<body>" +
"<iframe id=\"player\" " +
"src=\"https://www.youtube-nocookie.com/embed/aqz-KE-bpKQ?autoplay=1&mute=1&enablejsapi=1\" " +
"referrerpolicy=\"strict-origin-when-cross-origin\" " +
"allow=\"autoplay; encrypted-media; picture-in-picture\" " +
"allowfullscreen></iframe>" +
"</body>" +
"</html>";
byte[] bytes = html.getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=UTF-8");
exchange.getResponseHeaders().set("Referrer-Policy", "strict-origin-when-cross-origin");
exchange.sendResponseHeaders(200, bytes.length);
try (OutputStream os = exchange.getResponseBody()) {
os.write(bytes);
}
});
server.start();
int serverPort = server.getAddress().getPort();
// First, navigate to youtube.com to inject consent cookies to bypass EU/GDPR consent walls
try {
// Wait for the network interceptor to tell us if it got the data
Exception result = interceptorFuture.get(20, TimeUnit.SECONDS);
if (result != null)
throw new Exception("Failed to read YouTube player request body", result);
} catch (TimeoutException e) {
throw new Exception("Timed out waiting for YouTube player request");
driver.get("https://www.youtube.com");
driver.manage().addCookie(new Cookie.Builder("SOCS", "CAESEwgDEgk0ODE3Nzk3MjQaAmVuIAEaBgiA_LyaBg")
.domain(".youtube.com")
.path("/")
.build());
driver.manage().addCookie(new Cookie.Builder("CONSENT", "PENDING+999")
.domain(".youtube.com")
.path("/")
.build());
} catch (Exception e) {
LOGGER.debug("Could not pre-set consent cookies: {}", e.getMessage());
}
// Navigate to local embed wrapper page
driver.get("http://127.0.0.1:" + serverPort + "/embed");
WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(10));
// Wait for iframe and switch to it to interact with player
try {
wait.until(ExpectedConditions.frameToBeAvailableAndSwitchToIt(By.id("player")));
// Dismiss any consent dialog inside the iframe if present
try {
List<WebElement> consentButtons = driver.findElements(By.xpath(
"//button[contains(@aria-label, 'Agree') or contains(@aria-label, 'Accept') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'agree') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'accept') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'alle akzeptieren') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'ich stimme zu') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'tout accepter') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'accepter tout') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'accetta tutto') or " +
"contains(translate(., 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'aceptar todo')]"
));
for (WebElement btn : consentButtons) {
if (btn.isDisplayed()) {
LOGGER.info("Dismissing consent dialog inside iframe...");
btn.click();
Thread.sleep(1000);
break;
}
}
} catch (Exception ignored) {}
// Try to click player inside iframe to trigger playback and /player request
try {
WebElement moviePlayer = wait.until(ExpectedConditions.presenceOfElementLocated(
By.cssSelector("#movie_player, .ytp-large-play-button, .html5-video-player, video")
));
moviePlayer.click();
} catch (Exception ignored) {
((JavascriptExecutor) driver).executeScript(
"var p = document.getElementById('movie_player') || document.querySelector('video'); " +
"if (p) { if (p.playVideo) p.playVideo(); else if (p.play) p.play(); }"
);
}
// Switch back to default context so performance logging and main window operations work
driver.switchTo().defaultContent();
} catch (Exception e) {
LOGGER.debug("Could not interact with embed iframe: {}", e.getMessage());
try {
driver.switchTo().defaultContent();
} catch (Exception ignored) {}
}
// Poll performance logs to capture the /youtubei/v1/player request
boolean success = false;
long deadline = System.currentTimeMillis() + 25_000;
long nextActionTime = System.currentTimeMillis() + 5_000;
while (System.currentTimeMillis() < deadline && !success) {
LogEntries logEntries = driver.manage().logs().get(LogType.PERFORMANCE);
for (LogEntry entry : logEntries) {
try {
JSONObject json = new JSONObject(entry.getMessage());
JSONObject message = json.optJSONObject("message");
if (message == null) continue;
String method = message.optString("method");
if ("Network.requestWillBeSent".equals(method)) {
JSONObject params = message.optJSONObject("params");
if (params == null) continue;
JSONObject req = params.optJSONObject("request");
if (req == null) continue;
String url = req.optString("url", "");
if (url.contains("/youtubei/v1/player")) {
String postData = decodePostData(req);
if (postData == null || postData.isEmpty()) {
LOGGER.warn("Saw YouTube player request without a readable body, waiting for another...");
continue;
}
JSONObject body;
try {
body = new JSONObject(postData);
} catch (Exception e) {
LOGGER.warn("YouTube player request body is not JSON ({}), waiting for another...", e.getMessage());
continue;
}
String visitorData = null;
if (body.has("context") && body.getJSONObject("context").has("client")) {
visitorData = body.getJSONObject("context").getJSONObject("client").optString("visitorData", null);
}
String poToken = null;
if (body.has("serviceIntegrityDimensions")) {
poToken = body.getJSONObject("serviceIntegrityDimensions").optString("poToken", null);
}
if (visitorData != null && poToken != null) {
LOGGER.info("Successfully retrieved PO token & visitor data for YouTube playback.");
LOGGER.debug("PO token: {}", poToken);
LOGGER.debug("Visitor Data: {}", visitorData);
// Set PO token and visitor data on both Web and WebEmbedded clients
Web.setPoTokenAndVisitorData(poToken, visitorData);
WebEmbedded.setPoTokenAndVisitorData(poToken, visitorData);
success = true;
break;
}
}
}
} catch (Exception e) {
LOGGER.debug("Error parsing performance log entry: {}", e.getMessage());
}
}
if (success) break;
// If still not captured after a while, try re-triggering play inside iframe
if (System.currentTimeMillis() > nextActionTime) {
try {
driver.switchTo().frame("player");
((JavascriptExecutor) driver).executeScript(
"var p = document.getElementById('movie_player') || document.querySelector('video'); " +
"if (p) { if (p.playVideo) p.playVideo(); else if (p.play) p.play(); }"
);
driver.switchTo().defaultContent();
} catch (Exception ignored) {}
nextActionTime = System.currentTimeMillis() + 5_000;
}
Thread.sleep(500);
}
if (!success) {
saveDebugPage(driver);
throw new Exception("Timed out waiting for YouTube player request. Current URL: " + driver.getCurrentUrl());
}
} catch (Exception e) {
LOGGER.error("Failed to obtain PO token for YouTube playback: {}", e.getMessage());
LOGGER.debug("Exception:", e);
} finally {
interceptor.close();
// Only quit the browser when not in headless mode. Helps with troubleshooting.
if (server != null) {
try {
server.stop(0);
} catch (Exception ignored) {}
}
// Only quit the browser when in headless mode. Helps with troubleshooting.
if (headless)
driver.quit();
}
}
/**
* Extracts the request body of a captured Network.requestWillBeSent event.
* Prefers the raw Base64 postDataEntries (Chrome 130+) over the lossy postData string,
* and transparently gunzips the body when the client sent it with Content-Encoding: gzip
* (YouTube web client 2.20260918+ compresses the /youtubei/v1/player request).
*/
private static String decodePostData(JSONObject req) {
byte[] raw = null;
org.json.JSONArray entries = req.optJSONArray("postDataEntries");
if (entries != null) {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
for (int i = 0; i < entries.length(); i++) {
JSONObject entryObj = entries.optJSONObject(i);
if (entryObj != null && entryObj.has("bytes")) {
try {
buf.write(java.util.Base64.getDecoder().decode(entryObj.getString("bytes")));
} catch (Exception ignored) {}
}
}
if (buf.size() > 0) raw = buf.toByteArray();
}
if (raw == null) {
String postData = req.optString("postData", null);
if (postData == null || postData.isEmpty()) return null;
raw = postData.getBytes(StandardCharsets.ISO_8859_1);
}
if (raw.length > 2 && (raw[0] & 0xff) == 0x1f && (raw[1] & 0xff) == 0x8b) {
try (GZIPInputStream gz = new GZIPInputStream(new ByteArrayInputStream(raw))) {
raw = gz.readAllBytes();
} catch (IOException e) {
LOGGER.warn("Failed to gunzip YouTube player request body: {}", e.getMessage());
return null;
}
}
return new String(raw, StandardCharsets.UTF_8);
}
private static void saveDebugPage(WebDriver driver) {
try {
StringBuilder sb = new StringBuilder();
String currentUrl = driver.getCurrentUrl();
sb.append("<!-- Current URL: ").append(currentUrl).append(" -->\n");
sb.append("<!-- MAIN PAGE SOURCE -->\n");
try {
sb.append(driver.getPageSource()).append("\n\n");
} catch (Exception ignored) {}
try {
driver.switchTo().frame("player");
sb.append("<!-- IFRAME SOURCE -->\n");
sb.append(driver.getPageSource()).append("\n");
driver.switchTo().defaultContent();
} catch (Exception ignored) {}
File debugFile = new File("youtube_debug.html");
try (FileWriter writer = new FileWriter(debugFile)) {
writer.write(sb.toString());
}
LOGGER.info("Saved YouTube debug page source to {} (Current URL: {})", debugFile.getAbsolutePath(), currentUrl);
} catch (Exception e) {
LOGGER.warn("Failed to save YouTube debug page source: {}", e.getMessage());
}
}
}
+17
View File
@@ -63,6 +63,13 @@ youtubeoauth2=false
songinstatus=false
// If you set this to true, the bot will maintain a single status message in the text channel
// showing the currently playing song and queue summary. Set this to false to disable
// the status message feature entirely.
statusmessages=true
// If you set this, the bot will also use this prefix in addition to
// the one provided above
@@ -230,6 +237,16 @@ chromedriverpath = "AUTO"
chromeheadless = true
// Data logging (analytics) configuration
// Enable to log queue/playback events into a SQL database.
// SQLite example: jdbc:sqlite:musicbot.db
dblog.enabled = false
dblog.jdbc_url = "jdbc:sqlite:musicbot.db"
dblog.user = ""
dblog.password = ""
// This configures the routing planner to use for IP rotation on YouTube.
// When set, you need to also set an IP block below.
// The following routing strategies are available:
@@ -0,0 +1,211 @@
/*
* Copyright 2026
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
*/
package com.jagrosh.jmusicbot;
import com.jagrosh.jmusicbot.datalog.DataLogService;
import com.jagrosh.jmusicbot.datalog.StatsAccess;
import com.jagrosh.jmusicbot.datalog.StatsFilters;
import com.jagrosh.jmusicbot.datalog.StatsMessageFormatter;
import com.jagrosh.jmusicbot.datalog.StatsSummary;
import com.jagrosh.jmusicbot.datalog.StatsTimeRange;
import com.jagrosh.jmusicbot.datalog.StatsRow;
import java.lang.reflect.Field;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.Statement;
import java.util.List;
import java.util.Arrays;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.MessageEmbed;
import org.junit.Test;
import static org.junit.Assert.*;
public class StatsReportingTest
{
@Test
public void parsesRangesAndSources()
{
assertEquals("all", StatsTimeRange.parse("all").getKey());
assertEquals("today", StatsTimeRange.parse("today").getKey());
assertEquals("week", StatsTimeRange.parse("week").getKey());
assertEquals("month", StatsTimeRange.parse("month").getKey());
assertEquals("year", StatsTimeRange.parse("year").getKey());
assertEquals("60d", StatsTimeRange.parse("60d").getKey());
assertEquals("1y", StatsTimeRange.parse("1y").getKey());
assertEquals("2026-04", StatsTimeRange.month(2026, 4).getKey());
assertEquals("2025", StatsTimeRange.year(2025).getKey());
assertNull(StatsTimeRange.month(2026, 13));
assertNull(StatsTimeRange.parse("forever-ish"));
assertEquals("youtube", StatsFilters.normalizeSource("yt"));
assertEquals("soundcloud", StatsFilters.normalizeSource("SC"));
assertNull(StatsFilters.normalizeSource("all"));
}
@Test
public void userStatsRespectSelfOrAdminAccess()
{
assertTrue(StatsAccess.canViewUserStats(1L, 1L, false));
assertTrue(StatsAccess.canViewUserStats(1L, 2L, true));
assertFalse(StatsAccess.canViewUserStats(1L, 2L, false));
}
@Test
public void chunkedFieldsStayWithinDiscordLimit()
{
List<StatsRow> rows = Arrays.asList(
new StatsRow(1L, repeat("Very Long Track Name ", 20), "Artist", 10L, 0L),
new StatsRow(2L, repeat("Another Long Track Name ", 20), "Artist", 9L, 0L),
new StatsRow(3L, repeat("Third Long Track Name ", 20), "Artist", 8L, 0L));
EmbedBuilder builder = new EmbedBuilder();
StatsMessageFormatter.addChunkedField(builder, "Tracks", StatsMessageFormatter.countList(rows, "plays", 3), false);
MessageEmbed embed = builder.build();
assertTrue(embed.getFields().size() > 1);
for(MessageEmbed.Field field : embed.getFields())
assertTrue(field.getValue().length() <= 1024);
}
@Test
public void aggregatesPlaybackStatsFromSqlite() throws Exception
{
String dbName = "stats-test-" + System.nanoTime();
DataLogService service = new DataLogService(null, "jdbc:sqlite:file:" + dbName + "?mode=memory&cache=shared", "", "");
try
{
service.getStatsSummary(1L, StatsTimeRange.all(), null, null);
Connection connection = getConnection(service);
seedStatsData(connection);
StatsSummary server = service.getStatsSummary(1L, StatsTimeRange.all(), null, null);
assertEquals(5L, server.getTotalPlays());
assertEquals(305000L, server.getTotalListeningMillis());
assertEquals("Song A", server.getTopTracks().get(0).getLabel());
assertEquals(3L, server.getTopTracks().get(0).getCount());
assertEquals("youtube", server.getTopSources().get(0).getLabel());
assertEquals(2L, server.getSkipVotes().get(0).getCount());
assertEquals(1L, server.getSkipActions().get(0).getCount());
StatsSummary user = service.getStatsSummary(1L, StatsTimeRange.all(), null, 100L);
assertEquals(2L, user.getTotalPlays());
assertEquals(120000L, user.getTotalListeningMillis());
StatsSummary soundcloud = service.getStatsSummary(1L, StatsTimeRange.all(), "soundcloud", null);
assertEquals(2L, soundcloud.getTotalPlays());
assertEquals("Song B", soundcloud.getTopTracks().get(0).getLabel());
List<StatsRow> requestedByAlice = service.getTopRequestedTracks(1L, StatsTimeRange.all(), null, java.util.Collections.singletonList(100L), 5);
assertEquals(1, requestedByAlice.size());
assertEquals("Song A", requestedByAlice.get(0).getLabel());
assertEquals(2L, requestedByAlice.get(0).getCount());
List<StatsRow> listenedByBob = service.getTopListenedTracks(1L, StatsTimeRange.all(), null, java.util.Collections.singletonList(200L), 5);
assertEquals("Song A", listenedByBob.get(0).getLabel());
assertEquals(150000L, listenedByBob.get(0).getMillis());
List<StatsRow> profileMatches = service.findUsersByProfile(1L, "ali", 5);
assertEquals(1, profileMatches.size());
assertEquals(100L, profileMatches.get(0).getId());
}
finally
{
service.shutdown();
}
}
private Connection getConnection(DataLogService service) throws Exception
{
Field field = DataLogService.class.getDeclaredField("connection");
field.setAccessible(true);
return (Connection)field.get(service);
}
private void seedStatsData(Connection connection) throws Exception
{
long now = System.currentTimeMillis();
try(Statement st = connection.createStatement())
{
st.execute("INSERT INTO guilds (guild_id, name, first_seen_at) VALUES (1, 'Guild', " + now + ")");
st.execute("INSERT INTO users (user_id) VALUES (100)");
st.execute("INSERT INTO users (user_id) VALUES (200)");
st.execute("INSERT INTO user_profiles (user_id, guild_id, username, seen_at) VALUES (100, 1, 'Alice', " + now + ")");
st.execute("INSERT INTO user_profiles (user_id, guild_id, username, seen_at) VALUES (200, 1, 'Bob', " + now + ")");
st.execute("INSERT INTO tracks (id, source, identifier, uri, title, author, length_ms, is_stream) VALUES (1, 'youtube', 'a', 'https://youtu.be/a', 'Song A', 'Artist A', 120000, 0)");
st.execute("INSERT INTO tracks (id, source, identifier, uri, title, author, length_ms, is_stream) VALUES (2, 'soundcloud', 'b', 'https://soundcloud.com/b', 'Song B', 'Artist B', 180000, 0)");
}
try(PreparedStatement ps = connection.prepareStatement("INSERT INTO play_sessions (id, guild_id, track_id, requested_by, started_at, ended_at, duration_ms, listeners_at_start) VALUES (?,1,?,?,?,?,?,?)"))
{
insertPlay(ps, 1, 1, 100, now - 300000, now - 180000, 120000, 2);
insertPlay(ps, 2, 1, 100, now - 170000, now - 50000, 120000, 1);
insertPlay(ps, 3, 2, 200, now - 40000, now - 10000, 30000, 1);
insertPlay(ps, 4, 2, 200, now - 10000, now - 5000, 5000, 1);
insertPlay(ps, 5, 1, 200, now - 1000000000L, 0L, 0L, 1);
}
try(PreparedStatement ps = connection.prepareStatement("INSERT INTO listener_sessions (play_session_id, user_id, joined_at, left_at) VALUES (?,?,?,?)"))
{
insertListener(ps, 1, 100, now - 300000, now - 240000);
insertListener(ps, 2, 100, now - 170000, now - 110000);
insertListener(ps, 3, 200, now - 40000, now - 10000);
insertListener(ps, 1, 200, now - 300000, now - 270000);
insertListener(ps, 4, 200, now - 20000, 0L);
insertListener(ps, 5, 200, now - 1000000000L, 0L);
}
try(PreparedStatement ps = connection.prepareStatement("INSERT INTO queue_events (guild_id, user_id, track_id, event_type, created_at) VALUES (1,?,?,?,?)"))
{
insertQueueEvent(ps, 100, 1, "SKIP_VOTE", now - 100000);
insertQueueEvent(ps, 100, 1, "SKIP_VOTE", now - 90000);
insertQueueEvent(ps, 200, 1, "SKIP_RATIO_MET", now - 80000);
}
}
private void insertPlay(PreparedStatement ps, long id, long trackId, long requestedBy, long startedAt, long endedAt, long duration, int listeners) throws Exception
{
ps.setLong(1, id);
ps.setLong(2, trackId);
ps.setLong(3, requestedBy);
ps.setLong(4, startedAt);
if(endedAt == 0L)
ps.setNull(5, java.sql.Types.BIGINT);
else
ps.setLong(5, endedAt);
if(duration == 0L)
ps.setNull(6, java.sql.Types.BIGINT);
else
ps.setLong(6, duration);
ps.setInt(7, listeners);
ps.executeUpdate();
}
private void insertListener(PreparedStatement ps, long sessionId, long userId, long joinedAt, long leftAt) throws Exception
{
ps.setLong(1, sessionId);
ps.setLong(2, userId);
ps.setLong(3, joinedAt);
if(leftAt == 0L)
ps.setNull(4, java.sql.Types.BIGINT);
else
ps.setLong(4, leftAt);
ps.executeUpdate();
}
private void insertQueueEvent(PreparedStatement ps, long userId, long trackId, String eventType, long createdAt) throws Exception
{
ps.setLong(1, userId);
ps.setLong(2, trackId);
ps.setString(3, eventType);
ps.setLong(4, createdAt);
ps.executeUpdate();
}
private String repeat(String value, int count)
{
StringBuilder builder = new StringBuilder();
for(int i = 0; i < count; i++)
builder.append(value);
return builder.toString();
}
}