Compare commits

...
6 Commits
Author SHA1 Message Date
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
23 changed files with 2325 additions and 85 deletions

No files matched your search

+11 -9
View File
@@ -6,6 +6,8 @@ A self-hosted Discord music bot fork based on JMusicBot, updated to keep working
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.
Also, we somewhat regularly bump [lavaplayer](https://mvnrepository.com/artifact/dev.arbjerg/lavaplayer)/[lavalink](https://maven.lavalink.dev/#/releases/dev/lavalink/youtube/common) to their new versions. I will try to update this automatically in the future.
## Demo
Temporary invite: [discord.gg/4cp9CvjwaW](https://discord.gg/4cp9CvjwaW)
@@ -25,9 +27,9 @@ Permanent invite: [discord.gg/hK95396uB3](https://discord.gg/hK95396uB3)
## 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)
- Fork changes and version notes: [docs/fork-changelog.md](/docs/fork-changelog.md)
- Setup guide: [docs/setup.md](/docs/setup.md)
- Docker usage: [docs/docker.md](/docs/docker.md)
## Features
@@ -70,13 +72,13 @@ If you only need the essentials:
3. Configure `config.txt`.
4. Run the jar directly or use Docker.
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
A Docker image is published here: [docker.io/chrisb09/jmusicbot](https://hub.docker.com/r/chrisb09/jmusicbot)
For compose examples and deployment notes, see [docs/docker.md](/git/MusicBot/docs/docker.md).
For compose examples and deployment notes, see [docs/docker.md](/docs/docker.md).
## YouTube Login
@@ -90,15 +92,15 @@ 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).
More details are in [docs/setup.md](/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).
If you use PO tokens or visitor data for YouTube, those details are also covered 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.
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). You probably need to build the library from source for your architecture, so have fun with that.
## Upstream And Related Work
@@ -119,4 +121,4 @@ If you run into issues, please include:
- 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.
Fork-specific version notes live in [docs/fork-changelog.md](/docs/fork-changelog.md), which should help with troubleshooting and migration as the fork evolves.
+2
View File
@@ -72,6 +72,8 @@ 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.
The `stats` command and scheduled server rewind embeds read from this database, so reports only include activity logged after analytics were enabled.
## 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.
+15
View File
@@ -148,6 +148,21 @@ 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.
+4 -4
View File
@@ -3,7 +3,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>com.jagrosh</groupId>
<artifactId>JMusicBot</artifactId>
<version>0.6.5</version>
<version>0.6.7</version>
<packaging>jar</packaging>
<repositories>
@@ -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.18.1</version>
<version>f45bbb7aebfcbc1c553769e04af6cd43afa8b7c3-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>com.github.jagrosh</groupId>
@@ -203,7 +203,7 @@
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.27.0</version>
<version>4.47.0</version>
<!-- https://github.com/SeleniumHQ/selenium/issues/13791 -->
<exclusions>
<exclusion>
+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 ->
{
@@ -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
@@ -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;
}
}
}
@@ -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;
}
}
@@ -4,28 +4,37 @@ 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.File;
import java.io.FileWriter;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
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 +114,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 +141,247 @@ 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 = req.optString("postData", null);
// In Chrome 130+, request payload may be in postDataEntries as Base64 bytes
if ((postData == null || postData.isEmpty()) && req.has("postDataEntries")) {
org.json.JSONArray entries = req.optJSONArray("postDataEntries");
if (entries != null) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < entries.length(); i++) {
JSONObject entryObj = entries.optJSONObject(i);
if (entryObj != null && entryObj.has("bytes")) {
try {
byte[] decoded = java.util.Base64.getDecoder().decode(entryObj.getString("bytes"));
sb.append(new String(decoded, StandardCharsets.UTF_8));
} catch (Exception ignored) {}
}
}
if (sb.length() > 0) {
postData = sb.toString();
}
}
}
if (postData == null || postData.isEmpty()) continue;
JSONObject body = new JSONObject(postData);
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();
}
}
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();
}
}