Compare commits

...
Author SHA1 Message Date
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
20 changed files with 2017 additions and 4 deletions

No files matched your search

+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.
+1 -1
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.6</version>
<packaging>jar</packaging>
<repositories>
@@ -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;
@@ -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
{
@@ -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;
}
}
@@ -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();
}
}