Compare commits

...
11 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
41 changed files with 3076 additions and 561 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)
+1 -1
View File
@@ -6,4 +6,4 @@
*.txt
nb*.xml
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
+34 -53
View File
@@ -1,33 +1,31 @@
<img align="right" src="https://i.imgur.com/zrE80HY.png" height="200" width="200">
# JMusicBot Fork
# JMusicBot (TrueOG Network fork)
A self-hosted Discord music bot fork based on JMusicBot, updated to keep working on modern Discord and JDA versions.
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).
This repository keeps the bot practical to run today, while also adding fork-specific features such as status messages and playback analytics logging. Possibly more.
The upstream fork keeps the bot working on current Discord, JDA, and YouTube; this fork keeps it running on TrueOG's infrastructure.
## Demo
## TrueOG Network Fork Changes
Temporary invite: [discord.gg/4cp9CvjwaW](https://discord.gg/4cp9CvjwaW)
Permanent invite: [discord.gg/hK95396uB3](https://discord.gg/hK95396uB3)
- 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
## What This Fork Adds
- Make it work again, through:
- JDA 6 support and current dependency updates
- DAVE voice encryption support for current Discord voice requirements
- Optional YouTube account login support
- Optional YouTube PO token and visitor-data support
- New niche features, such as:
- Optional per-channel playback status message
- Optional JDBC/SQLite playback analytics logging
- Basic QoL improvements, such as:
- Docker image and container-friendly deployment path
## Inherited From Upstream (chrisb09)
- 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
## Quick Links
- Fork changes and version notes: [docs/fork-changelog.md](/git/MusicBot/docs/fork-changelog.md)
- Setup guide: [docs/setup.md](/git/MusicBot/docs/setup.md)
- Docker usage: [docs/docker.md](/git/MusicBot/docs/docker.md)
- Setup guide: [docs/setup.md](/docs/setup.md)
- Upstream fork changelog: [docs/fork-changelog.md](/docs/fork-changelog.md)
## Features
@@ -61,44 +59,40 @@ Formats:
- AAC streams
- Stream playlists such as M3U and PLS
## Setup Notes
## Building
If you only need the essentials:
```bash
./mvnw clean package
```
The runnable jar is written to `target/JMusicBot-<version>-All.jar`.
## Setup Notes
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 directly or use Docker.
4. Run the jar: `java -Dnogui=true -jar target/JMusicBot-<version>-All.jar`
For the detailed version, see [docs/setup.md](/git/MusicBot/docs/setup.md).
For the detailed version, see [docs/setup.md](/docs/setup.md).
## Docker
## YouTube Playback
A Docker image is published here: [docker.io/chrisb09/jmusicbot](https://hub.docker.com/r/chrisb09/jmusicbot)
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.
For compose examples and deployment notes, see [docs/docker.md](/git/MusicBot/docs/docker.md).
## YouTube Login
This fork supports Google account login for YouTube playback workarounds.
Enable:
Google account login is also supported as a fallback:
```conf
youtubeoauth2=true
```
Do not use your main Google account. Use a burner or otherwise disposable account instead.
More details are in [docs/setup.md](/git/MusicBot/docs/setup.md).
If you use PO tokens or visitor data for YouTube, those details are also covered in [docs/setup.md](/git/MusicBot/docs/setup.md).
Do not use your main Google account. Use a burner or otherwise disposable account instead. Details are in [docs/setup.md](/docs/setup.md).
## DAVE / Voice Encryption
Discord currently requires DAVE for voice on modern API versions. This fork configures `libdave-jvm` during startup and bundles native binaries for common Linux, Windows, and macOS targets.
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.
If you deploy on an unsupported architecture, add the matching native dependency in [pom.xml](/git/MusicBot/pom.xml). You probably need to build the library from source for your architecture, so have fun with that.
If you deploy on an unsupported architecture, add the matching native dependency in [pom.xml](/pom.xml).
## Upstream And Related Work
@@ -107,16 +101,3 @@ This fork incorporates ideas and patches related to:
- [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)
If this fork does not fit your needs, another fork you may want to inspect is [SeVile/MusicBot](https://github.com/SeVile/MusicBot).
## Questions And Contributions
If you run into issues, please include:
- the version you are running
- whether you are using Docker or the jar directly
- relevant config changes
- relevant logs
Fork-specific version notes live in [docs/fork-changelog.md](/git/MusicBot/docs/fork-changelog.md), which should help with troubleshooting and migration as the fork evolves.
-93
View File
@@ -1,93 +0,0 @@
# Docker Guide
This fork supports running the bot in Docker, which is usually the easiest deployment path for a server.
## Image
Published image:
- [docker.io/chrisb09/jmusicbot](https://hub.docker.com/r/chrisb09/jmusicbot)
Container build sources:
- [github.com/chrisb09/jmb-container](https://github.com/chrisb09/jmb-container)
## Minimal Compose Example
```yaml
version: "2.2"
services:
jmusicbot:
image: chrisb09/jmusicbot:latest
container_name: jmusicbot
restart: unless-stopped
volumes:
- ./config:/jmb/config
```
Replace `./config` with the host path you actually want to use.
## Config Files
Your persistent bot config should live in the mounted config directory. That lets you redeploy containers without losing your bot settings.
Typical things to configure there:
- bot token
- owner ID
- prefix
- YouTube login toggle
- YouTube PO token and visitor-data values if needed
- status message toggle
- analytics logging settings
## Deploy Flow
A typical update flow is:
1. Pull or rebuild the image.
2. Restart the container.
3. Check logs for startup errors.
Useful commands:
```bash
docker-compose up -d
docker-compose logs -f jmusicbot
```
## Alpine / musl Note
This fork bundles native support for Alpine-compatible musl environments as part of its DAVE setup, so Alpine-based container deployments are supported.
## SQLite Logging In Docker
If you enable analytics logging with SQLite:
```conf
dblog.enabled = true
dblog.jdbc_url = "jdbc:sqlite:musicbot.db"
```
make sure the database file lives somewhere inside your mounted persistent config or data path if you want it to survive container recreation.
## YouTube Token Notes In Docker
If you use YouTube login or PO token based configuration, keep those values in your persistent mounted config rather than baking them into the image.
Relevant config values include:
```conf
youtubeoauth2 = true
ytpotoken = "PO_TOKEN_HERE"
ytvisitordata = "VISITOR_DATA_HERE"
```
## Recommendation
For production-style use, Docker is the cleanest path if you want:
- reproducible runtime behavior
- simple upgrades
- easier separation between bot code and persistent config
+18 -3
View File
@@ -1,6 +1,6 @@
# Setup Guide
This guide covers the normal non-Docker setup path and the main configuration points that matter on this fork.
This guide covers the normal setup path and the main configuration points that matter on this fork.
## Prerequisites
@@ -61,7 +61,7 @@ ytvisitordata = "VISITOR_DATA_HERE"
Build:
```bash
mvn clean package
./mvnw clean package
```
Run:
@@ -148,9 +148,24 @@ 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 directly on Windows rather than in Docker, install Java first and consider a service wrapper such as [NSSM](http://nssm.cc/) if you want it to run in the background.
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
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"
+23 -14
View File
@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.jagrosh</groupId>
<artifactId>JMusicBot</artifactId>
<version>0.6.3</version>
<version>0.6.7</version>
<packaging>jar</packaging>
<repositories>
@@ -94,25 +94,25 @@
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-command</artifactId>
<version>2.2.1</version>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-commons</artifactId>
<version>2.2.1</version>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-examples</artifactId>
<version>2.2.1</version>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<dependency>
<groupId>pw.chew</groupId>
<artifactId>jda-chewtils-menu</artifactId>
<version>2.2.1</version>
<version>${jda.chewtils.version}</version>
<scope>compile</scope>
</dependency>
<!-- <dependency>
@@ -151,7 +151,7 @@
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavaplayer</artifactId>
<version>2.2.4</version>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>dev.arbjerg</groupId>
@@ -161,7 +161,7 @@
<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>
@@ -203,20 +203,17 @@
<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>
@@ -244,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>
@@ -264,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>
@@ -287,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}
+10 -1
View File
@@ -24,6 +24,7 @@ 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;
@@ -49,6 +50,7 @@ public class Bot
private final StatusMessageHandler statusMessageHandler;
private final AloneInVoiceHandler aloneInVoiceHandler;
private final DataLogService dataLogService;
private final StatsReportScheduler statsReportScheduler;
private final YoutubeOauth2TokenHandler youTubeOauth2TokenHandler;
private boolean shuttingDown = false;
@@ -85,6 +87,8 @@ public class Bot
}
}
this.dataLogService = dls;
this.statsReportScheduler = new StatsReportScheduler(this);
this.statsReportScheduler.init();
}
public BotConfig getConfig()
@@ -137,6 +141,11 @@ public class Bot
return dataLogService;
}
public StatsReportScheduler getStatsReportScheduler()
{
return statsReportScheduler;
}
public YoutubeOauth2TokenHandler getYouTubeOauth2Handler()
{
return youTubeOauth2TokenHandler;
@@ -167,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 ->
{
@@ -196,7 +196,7 @@ public class JMusicBot
{
// instantiate about command
LoggedAboutCmd aboutCommand = new LoggedAboutCmd(bot, Color.BLUE.brighter(),
"a music bot that is [easy to host yourself!](https://github.com/jagrosh/MusicBot) (v" + OtherUtil.getCurrentVersion() + ")",
"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);
@@ -214,6 +214,7 @@ public class JMusicBot
.addCommands(aboutCommand,
new LoggedPingCmd(bot),
new SettingsCmd(bot),
new StatsCmd(bot),
new LyricsCmd(bot),
new NowplayingCmd(bot),
@@ -65,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)
{
@@ -239,6 +241,13 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
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;
}
}
@@ -286,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();
}
@@ -341,6 +352,9 @@ public class AudioHandler extends AudioEventAdapter implements AudioSendHandler
.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
{
@@ -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
@@ -18,28 +18,184 @@ 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 org.json.JSONObject;
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, net.dv8tion.jda.api.Permission[] perms)
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)
{
super.execute(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);
}
}
@@ -58,7 +58,9 @@ public class SettingsCmd extends LoggedCommand
.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("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 + " **")
@@ -66,6 +68,7 @@ public class SettingsCmd extends LoggedCommand
.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())
@@ -80,6 +83,8 @@ public class SettingsCmd extends LoggedCommand
? 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;
}
}
}
@@ -16,7 +16,6 @@
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;
@@ -67,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);
@@ -21,10 +21,15 @@ import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import java.sql.*;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.User;
@@ -259,6 +264,111 @@ public class DataLogService
});
}
public long getGuildTrackPlayCount(Guild guild, AudioTrack track)
{
return getGuildTrackPlayCount(guild, track, StatsTimeRange.all());
}
public long getGuildTrackPlayCount(Guild guild, AudioTrack track, StatsTimeRange range)
{
if(guild == null || track == null)
return 0L;
AudioTrackInfo info = track.getInfo();
return getGuildTrackPlayCount(guild.getIdLong(), inferSource(info), track.getIdentifier(), range);
}
public long getGuildTrackPlayCount(long guildId, String source, String identifier)
{
return getGuildTrackPlayCount(guildId, source, identifier, StatsTimeRange.all());
}
public long getGuildTrackPlayCount(long guildId, String source, String identifier, StatsTimeRange range)
{
if(identifier == null)
return 0L;
StatsTimeRange effectiveRange = range == null ? StatsTimeRange.all() : range;
return query(() ->
{
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM play_sessions ps JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=? AND t.source=? AND t.identifier=?");
if(effectiveRange.getStartMillis() != null)
sql.append(" AND ps.started_at>=?");
if(effectiveRange.getEndMillis() != null)
sql.append(" AND ps.started_at<?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
ps.setLong(1, guildId);
ps.setString(2, StatsFilters.normalizeSource(source));
ps.setString(3, identifier);
int index = 4;
if(effectiveRange.getStartMillis() != null)
ps.setLong(index++, effectiveRange.getStartMillis());
if(effectiveRange.getEndMillis() != null)
ps.setLong(index, effectiveRange.getEndMillis());
try(ResultSet rs = ps.executeQuery())
{
return rs.next() ? rs.getLong(1) : 0L;
}
}
}, 0L);
}
public StatsSummary getStatsSummary(long guildId, StatsTimeRange range, String source, Long userId)
{
StatsTimeRange effectiveRange = range == null ? StatsTimeRange.all() : range;
String effectiveSource = StatsFilters.normalizeSource(source);
return query(() -> buildStatsSummary(guildId, effectiveRange, effectiveSource, userId),
new StatsSummary(effectiveRange, effectiveSource, 0L, 0L,
Collections.emptyList(), Collections.emptyList(), Collections.emptyList(),
Collections.emptyList(), Collections.emptyList(), Collections.emptyList()));
}
public List<StatsRow> getTopRequestedTracks(long guildId, StatsTimeRange range, String source, List<Long> userIds, int limit)
{
StatsTimeRange effectiveRange = range == null ? StatsTimeRange.all() : range;
String effectiveSource = StatsFilters.normalizeSource(source);
return query(() -> topRequestedTracks(guildId, effectiveRange, effectiveSource, userIds, Math.max(1, limit)),
Collections.emptyList());
}
public List<StatsRow> getTopListenedTracks(long guildId, StatsTimeRange range, String source, List<Long> userIds, int limit)
{
StatsTimeRange effectiveRange = range == null ? StatsTimeRange.all() : range;
String effectiveSource = StatsFilters.normalizeSource(source);
return query(() -> topListenedTracks(guildId, effectiveRange, effectiveSource, userIds, Math.max(1, limit)),
Collections.emptyList());
}
public List<StatsRow> findUsersByProfile(long guildId, String queryText, int limit)
{
if(queryText == null || queryText.trim().isEmpty())
return Collections.emptyList();
String like = "%" + queryText.trim().toLowerCase() + "%";
return query(() ->
{
try(PreparedStatement ps = connection.prepareStatement(
"SELECT up.user_id, COALESCE(up.nickname, up.username) label FROM user_profiles up "
+ "WHERE up.guild_id=? AND up.seen_at=(SELECT MAX(seen_at) FROM user_profiles latest WHERE latest.guild_id=up.guild_id AND latest.user_id=up.user_id) "
+ "AND (LOWER(COALESCE(up.username, '')) LIKE ? OR LOWER(COALESCE(up.nickname, '')) LIKE ?) "
+ "ORDER BY label ASC LIMIT ?"))
{
ps.setLong(1, guildId);
ps.setString(2, like);
ps.setString(3, like);
ps.setInt(4, Math.max(1, limit));
List<StatsRow> rows = new ArrayList<>();
try(ResultSet rs = ps.executeQuery())
{
while(rs.next())
{
long userId = rs.getLong("user_id");
rows.add(new StatsRow(userId, userLabel(rs.getString("label"), userId), null, 0L, 0L));
}
}
return rows;
}
}, Collections.emptyList());
}
public void logQueueEventWithMeta(Guild guild, User user, AudioTrack track, String eventType, String source, Integer position, String query, String playlistName, String metaJson)
{
if(guild == null || eventType == null)
@@ -612,6 +722,360 @@ public class DataLogService
executor.submit(r);
}
private <T> T query(SqlSupplier<T> supplier, T fallback)
{
Future<T> future = executor.submit(() ->
{
try
{
return supplier.get();
}
catch(SQLException ex)
{
LOG.debug("stats query failed: {}", ex.getMessage());
return fallback;
}
});
try
{
return future.get(5, TimeUnit.SECONDS);
}
catch(Exception ex)
{
future.cancel(true);
LOG.debug("stats query timed out or failed: {}", ex.getMessage());
return fallback;
}
}
private StatsSummary buildStatsSummary(long guildId, StatsTimeRange range, String source, Long userId) throws SQLException
{
long totalPlays = countPlays(guildId, range, source, userId);
long totalListeningMillis = sumListeningMillis(guildId, range, source, userId);
return new StatsSummary(range, source, totalPlays, totalListeningMillis,
topTracks(guildId, range, source, userId, 10),
topUsersFromPlaySessions(guildId, range, source, userId, 10),
topListeners(guildId, range, source, userId, 10),
topSources(guildId, range, userId, 10),
topSkippers(guildId, range, source, userId, true, 10),
topSkippers(guildId, range, source, userId, false, 10));
}
private long countPlays(long guildId, StatsTimeRange range, String source, Long userId) throws SQLException
{
StringBuilder sql = new StringBuilder("SELECT COUNT(*) FROM play_sessions ps JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=?");
appendPlayFilters(sql, range, source, userId);
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
bindPlayFilters(ps, 1, guildId, range, source, userId);
try(ResultSet rs = ps.executeQuery())
{
return rs.next() ? rs.getLong(1) : 0L;
}
}
}
private long sumListeningMillis(long guildId, StatsTimeRange range, String source, Long userId) throws SQLException
{
String upperBound = boundedListenerUpperExpression();
StringBuilder sql = new StringBuilder("SELECT SUM(CASE "
+ "WHEN " + upperBound + " > MAX(ls.joined_at, COALESCE(ps.started_at, ls.joined_at)) "
+ "THEN " + upperBound + " - MAX(ls.joined_at, COALESCE(ps.started_at, ls.joined_at)) "
+ "ELSE 0 END) "
+ "FROM listener_sessions ls JOIN play_sessions ps ON ls.play_session_id=ps.id JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=?");
appendPlayFilters(sql, range, source, null);
if(userId != null)
sql.append(" AND ls.user_id=?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = 1;
long now = now();
ps.setLong(index++, now);
ps.setLong(index++, now);
ps.setLong(index++, now);
ps.setLong(index++, now);
index = bindPlayFilters(ps, index, guildId, range, source, null);
if(userId != null)
ps.setLong(index, userId);
try(ResultSet rs = ps.executeQuery())
{
return rs.next() ? rs.getLong(1) : 0L;
}
}
}
private List<StatsRow> topTracks(long guildId, StatsTimeRange range, String source, Long userId, int limit) throws SQLException
{
StringBuilder sql = new StringBuilder("SELECT t.id, t.title, t.author, COUNT(*) plays FROM play_sessions ps JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=?");
appendPlayFilters(sql, range, source, userId);
sql.append(" GROUP BY t.id, t.title, t.author ORDER BY plays DESC, t.title ASC LIMIT ?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = bindPlayFilters(ps, 1, guildId, range, source, userId);
ps.setInt(index, limit);
List<StatsRow> rows = new ArrayList<>();
try(ResultSet rs = ps.executeQuery())
{
while(rs.next())
rows.add(new StatsRow(rs.getLong("id"), safeLabel(rs.getString("title"), "Unknown track"), rs.getString("author"), rs.getLong("plays"), 0L));
}
return rows;
}
}
private List<StatsRow> topRequestedTracks(long guildId, StatsTimeRange range, String source, List<Long> userIds, int limit) throws SQLException
{
StringBuilder sql = new StringBuilder("SELECT t.id, t.title, t.author, COUNT(*) plays FROM play_sessions ps JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=?");
appendPlayFilters(sql, range, source, null);
appendUserListFilter(sql, "ps.requested_by", userIds);
sql.append(" GROUP BY t.id, t.title, t.author ORDER BY plays DESC, t.title ASC LIMIT ?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = bindPlayFilters(ps, 1, guildId, range, source, null);
index = bindUserList(ps, index, userIds);
ps.setInt(index, limit);
List<StatsRow> rows = new ArrayList<>();
try(ResultSet rs = ps.executeQuery())
{
while(rs.next())
rows.add(new StatsRow(rs.getLong("id"), safeLabel(rs.getString("title"), "Unknown track"), rs.getString("author"), rs.getLong("plays"), 0L));
}
return rows;
}
}
private List<StatsRow> topListenedTracks(long guildId, StatsTimeRange range, String source, List<Long> userIds, int limit) throws SQLException
{
String upperBound = boundedListenerUpperExpression();
StringBuilder sql = new StringBuilder("SELECT t.id, t.title, t.author, COUNT(*) listens, "
+ "SUM(CASE "
+ "WHEN " + upperBound + " > MAX(ls.joined_at, COALESCE(ps.started_at, ls.joined_at)) "
+ "THEN " + upperBound + " - MAX(ls.joined_at, COALESCE(ps.started_at, ls.joined_at)) "
+ "ELSE 0 END) listen_ms "
+ "FROM listener_sessions ls JOIN play_sessions ps ON ls.play_session_id=ps.id JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=?");
appendPlayFilters(sql, range, source, null);
appendUserListFilter(sql, "ls.user_id", userIds);
sql.append(" GROUP BY t.id, t.title, t.author ORDER BY listen_ms DESC, listens DESC, t.title ASC LIMIT ?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = 1;
long current = now();
ps.setLong(index++, current);
ps.setLong(index++, current);
ps.setLong(index++, current);
ps.setLong(index++, current);
index = bindPlayFilters(ps, index, guildId, range, source, null);
index = bindUserList(ps, index, userIds);
ps.setInt(index, limit);
List<StatsRow> rows = new ArrayList<>();
try(ResultSet rs = ps.executeQuery())
{
while(rs.next())
rows.add(new StatsRow(rs.getLong("id"), safeLabel(rs.getString("title"), "Unknown track"), rs.getString("author"), rs.getLong("listens"), rs.getLong("listen_ms")));
}
return rows;
}
}
private List<StatsRow> topUsersFromPlaySessions(long guildId, StatsTimeRange range, String source, Long userId, int limit) throws SQLException
{
StringBuilder sql = new StringBuilder("SELECT ps.requested_by user_id, " + userLabelSql("ps.requested_by", "ps.guild_id") + " label, COUNT(*) plays "
+ "FROM play_sessions ps JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=? AND ps.requested_by<>0");
appendPlayFilters(sql, range, source, userId);
sql.append(" GROUP BY ps.requested_by ORDER BY plays DESC LIMIT ?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = bindPlayFilters(ps, 1, guildId, range, source, userId);
ps.setInt(index, limit);
return readCountRows(ps, "user_id", "label", "plays");
}
}
private List<StatsRow> topListeners(long guildId, StatsTimeRange range, String source, Long userId, int limit) throws SQLException
{
String upperBound = boundedListenerUpperExpression();
StringBuilder sql = new StringBuilder("SELECT ls.user_id, " + userLabelSql("ls.user_id", "ps.guild_id") + " label, "
+ "SUM(CASE "
+ "WHEN " + upperBound + " > MAX(ls.joined_at, COALESCE(ps.started_at, ls.joined_at)) "
+ "THEN " + upperBound + " - MAX(ls.joined_at, COALESCE(ps.started_at, ls.joined_at)) "
+ "ELSE 0 END) listen_ms "
+ "FROM listener_sessions ls JOIN play_sessions ps ON ls.play_session_id=ps.id JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=?");
appendPlayFilters(sql, range, source, null);
if(userId != null)
sql.append(" AND ls.user_id=?");
sql.append(" GROUP BY ls.user_id ORDER BY listen_ms DESC LIMIT ?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = 1;
long current = now();
ps.setLong(index++, current);
ps.setLong(index++, current);
ps.setLong(index++, current);
ps.setLong(index++, current);
index = bindPlayFilters(ps, index, guildId, range, source, null);
if(userId != null)
ps.setLong(index++, userId);
ps.setInt(index, limit);
List<StatsRow> rows = new ArrayList<>();
try(ResultSet rs = ps.executeQuery())
{
while(rs.next())
rows.add(new StatsRow(rs.getLong("user_id"), userLabel(rs.getString("label"), rs.getLong("user_id")), null, 0L, rs.getLong("listen_ms")));
}
return rows;
}
}
private List<StatsRow> topSources(long guildId, StatsTimeRange range, Long userId, int limit) throws SQLException
{
StringBuilder sql = new StringBuilder("SELECT t.source, COUNT(*) plays FROM play_sessions ps JOIN tracks t ON ps.track_id=t.id WHERE ps.guild_id=?");
appendPlayFilters(sql, range, null, userId);
sql.append(" GROUP BY t.source ORDER BY plays DESC, t.source ASC LIMIT ?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = bindPlayFilters(ps, 1, guildId, range, null, userId);
ps.setInt(index, limit);
List<StatsRow> rows = new ArrayList<>();
try(ResultSet rs = ps.executeQuery())
{
while(rs.next())
rows.add(new StatsRow(0L, safeLabel(rs.getString("source"), "unknown"), null, rs.getLong("plays"), 0L));
}
return rows;
}
}
private List<StatsRow> topSkippers(long guildId, StatsTimeRange range, String source, Long userId, boolean votes, int limit) throws SQLException
{
StringBuilder sql = new StringBuilder("SELECT qe.user_id, " + userLabelSql("qe.user_id", "qe.guild_id") + " label, COUNT(*) skips "
+ "FROM queue_events qe LEFT JOIN tracks t ON qe.track_id=t.id WHERE qe.guild_id=? AND qe.user_id<>0");
if(votes)
sql.append(" AND qe.event_type='SKIP_VOTE'");
else
sql.append(" AND qe.event_type LIKE 'SKIP_%' AND qe.event_type<>'SKIP_VOTE'");
appendQueueFilters(sql, range, source, userId);
sql.append(" GROUP BY qe.user_id ORDER BY skips DESC LIMIT ?");
try(PreparedStatement ps = connection.prepareStatement(sql.toString()))
{
int index = bindQueueFilters(ps, 1, guildId, range, source, userId);
ps.setInt(index, limit);
return readCountRows(ps, "user_id", "label", "skips");
}
}
private List<StatsRow> readCountRows(PreparedStatement ps, String idColumn, String labelColumn, String countColumn) throws SQLException
{
List<StatsRow> rows = new ArrayList<>();
try(ResultSet rs = ps.executeQuery())
{
while(rs.next())
{
long id = rs.getLong(idColumn);
rows.add(new StatsRow(id, userLabel(rs.getString(labelColumn), id), null, rs.getLong(countColumn), 0L));
}
}
return rows;
}
private void appendPlayFilters(StringBuilder sql, StatsTimeRange range, String source, Long userId)
{
if(range != null && range.getStartMillis() != null)
sql.append(" AND ps.started_at>=?");
if(range != null && range.getEndMillis() != null)
sql.append(" AND ps.started_at<?");
if(source != null)
sql.append(" AND t.source=?");
if(userId != null)
sql.append(" AND ps.requested_by=?");
}
private int bindPlayFilters(PreparedStatement ps, int index, long guildId, StatsTimeRange range, String source, Long userId) throws SQLException
{
ps.setLong(index++, guildId);
if(range != null && range.getStartMillis() != null)
ps.setLong(index++, range.getStartMillis());
if(range != null && range.getEndMillis() != null)
ps.setLong(index++, range.getEndMillis());
if(source != null)
ps.setString(index++, source);
if(userId != null)
ps.setLong(index++, userId);
return index;
}
private void appendQueueFilters(StringBuilder sql, StatsTimeRange range, String source, Long userId)
{
if(range != null && range.getStartMillis() != null)
sql.append(" AND qe.created_at>=?");
if(range != null && range.getEndMillis() != null)
sql.append(" AND qe.created_at<?");
if(source != null)
sql.append(" AND t.source=?");
if(userId != null)
sql.append(" AND qe.user_id=?");
}
private int bindQueueFilters(PreparedStatement ps, int index, long guildId, StatsTimeRange range, String source, Long userId) throws SQLException
{
ps.setLong(index++, guildId);
if(range != null && range.getStartMillis() != null)
ps.setLong(index++, range.getStartMillis());
if(range != null && range.getEndMillis() != null)
ps.setLong(index++, range.getEndMillis());
if(source != null)
ps.setString(index++, source);
if(userId != null)
ps.setLong(index++, userId);
return index;
}
private void appendUserListFilter(StringBuilder sql, String column, List<Long> userIds)
{
if(userIds == null || userIds.isEmpty())
return;
sql.append(" AND ").append(column).append(" IN (");
for(int i = 0; i < userIds.size(); i++)
{
if(i > 0)
sql.append(",");
sql.append("?");
}
sql.append(")");
}
private int bindUserList(PreparedStatement ps, int index, List<Long> userIds) throws SQLException
{
if(userIds == null)
return index;
for(Long userId : userIds)
ps.setLong(index++, userId);
return index;
}
private String userLabelSql(String userIdExpression, String guildIdExpression)
{
return "(SELECT COALESCE(nickname, username) FROM user_profiles up WHERE up.user_id=" + userIdExpression + " AND up.guild_id=" + guildIdExpression + " ORDER BY seen_at DESC LIMIT 1)";
}
private String boundedListenerUpperExpression()
{
String fallbackEnd = "COALESCE(ps.ended_at, CASE "
+ "WHEN ps.duration_ms IS NOT NULL THEN COALESCE(ps.started_at, ls.joined_at) + ps.duration_ms "
+ "WHEN t.is_stream=0 AND t.length_ms IS NOT NULL THEN COALESCE(ps.started_at, ls.joined_at) + t.length_ms "
+ "ELSE ? END)";
return "MIN(COALESCE(ls.left_at, " + fallbackEnd + "), " + fallbackEnd + ")";
}
private String userLabel(String label, long userId)
{
return safeLabel(label, "<@" + userId + ">");
}
private String safeLabel(String label, String fallback)
{
return label == null || label.trim().isEmpty() ? fallback : label;
}
private static long now()
{
return Instant.now().toEpochMilli();
@@ -626,4 +1090,9 @@ public class DataLogService
return a.equals(b);
}
private interface SqlSupplier<T>
{
T get() throws SQLException;
}
}
@@ -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());
}
}
}
@@ -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();
}
}