/*
* Copyright 2026 Arif Banai (arif-banai)
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.jagrosh.jmusicbot.audio;
import com.sedmelluq.discord.lavaplayer.player.AudioLoadResultHandler;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.util.Arrays;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* Monitors track loading operations to track performance and success rates by source.
* This helps diagnose issues with specific audio sources (YouTube, Spotify, etc.).
*
*
Tracks for each load request:
*
* - Load duration (time from loadItem call to result)
* - Source manager (which audio source handled the request)
* - Load result (success, failure, no match)
* - Error messages for failures
*
*
* Implements {@link AudioLoadWrapper} for use with dependency injection.
* When GUI is disabled, {@link AudioLoadWrapper#NO_OP} should be used instead
* of this class to avoid allocating monitoring resources.
*
* @author Arif Banai (arif-banai)
*/
public final class TrackLoadingMonitor implements AudioLoadWrapper {
private static final Logger LOG = LoggerFactory.getLogger(TrackLoadingMonitor.class);
private static final int MAX_LOAD_EVENTS = 500;
private static final int MAX_EVENTS_PER_SOURCE = 100;
private static volatile TrackLoadingMonitor instance;
// Global load events
private final ConcurrentLinkedDeque loadEvents = new ConcurrentLinkedDeque<>();
// Per-source statistics
private final ConcurrentMap sourceStats = new ConcurrentHashMap<>();
// Global counters
private final AtomicLong totalLoads = new AtomicLong(0);
private final AtomicLong successfulLoads = new AtomicLong(0);
private final AtomicLong failedLoads = new AtomicLong(0);
private final AtomicLong noMatchLoads = new AtomicLong(0);
private TrackLoadingMonitor() {
// Private constructor for singleton
}
/**
* Gets the singleton instance.
*/
public static TrackLoadingMonitor getInstance() {
if (instance == null) {
synchronized (TrackLoadingMonitor.class) {
if (instance == null) {
instance = new TrackLoadingMonitor();
}
}
}
return instance;
}
/**
* Wraps an AudioLoadResultHandler to monitor loading performance.
* Implements the {@link AudioLoadWrapper} interface.
*
* @param query the search query or URL
* @param delegate the original handler to delegate to
* @return a wrapped handler that records metrics
*/
@Override
public AudioLoadResultHandler wrap(String query, AudioLoadResultHandler delegate) {
return createWrapper(query, delegate);
}
private AudioLoadResultHandler createWrapper(String query, AudioLoadResultHandler delegate) {
long startTime = System.currentTimeMillis();
return new AudioLoadResultHandler() {
@Override
public void trackLoaded(AudioTrack track) {
long duration = System.currentTimeMillis() - startTime;
String source = track.getSourceManager() != null
? track.getSourceManager().getSourceName()
: "unknown";
recordLoad(new LoadEvent(
System.currentTimeMillis(),
query,
source,
LoadResult.TRACK_LOADED,
duration,
track.getInfo().title,
null
));
delegate.trackLoaded(track);
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
long duration = System.currentTimeMillis() - startTime;
String source = "unknown";
if (!playlist.getTracks().isEmpty() && playlist.getTracks().get(0).getSourceManager() != null) {
source = playlist.getTracks().get(0).getSourceManager().getSourceName();
}
recordLoad(new LoadEvent(
System.currentTimeMillis(),
query,
source,
LoadResult.PLAYLIST_LOADED,
duration,
playlist.getName(),
null
));
delegate.playlistLoaded(playlist);
}
@Override
public void noMatches() {
long duration = System.currentTimeMillis() - startTime;
String source = guessSourceFromQuery(query);
recordLoad(new LoadEvent(
System.currentTimeMillis(),
query,
source,
LoadResult.NO_MATCHES,
duration,
null,
null
));
delegate.noMatches();
}
@Override
public void loadFailed(FriendlyException exception) {
long duration = System.currentTimeMillis() - startTime;
String source = guessSourceFromQuery(query);
recordLoad(new LoadEvent(
System.currentTimeMillis(),
query,
source,
LoadResult.LOAD_FAILED,
duration,
null,
exception.getMessage()
));
delegate.loadFailed(exception);
}
};
}
/**
* Guesses the source from the query URL.
*/
private String guessSourceFromQuery(String query) {
if (query == null) return "unknown";
query = query.toLowerCase();
if (query.contains("youtube.com") || query.contains("youtu.be")) {
return "youtube";
} else if (query.contains("soundcloud.com")) {
return "soundcloud";
} else if (query.contains("spotify.com")) {
return "spotify";
} else if (query.contains("twitch.tv")) {
return "twitch";
} else if (query.contains("bandcamp.com")) {
return "bandcamp";
} else if (query.contains("vimeo.com")) {
return "vimeo";
} else if (query.startsWith("http://") || query.startsWith("https://")) {
return "http";
}
return "search";
}
/**
* Records a load event.
*/
private void recordLoad(LoadEvent event) {
totalLoads.incrementAndGet();
switch (event.result()) {
case TRACK_LOADED, PLAYLIST_LOADED -> successfulLoads.incrementAndGet();
case LOAD_FAILED -> failedLoads.incrementAndGet();
case NO_MATCHES -> noMatchLoads.incrementAndGet();
}
// Add to global events
loadEvents.addLast(event);
while (loadEvents.size() > MAX_LOAD_EVENTS) {
loadEvents.pollFirst();
}
// Update source stats
sourceStats.computeIfAbsent(event.source(), k -> new SourceStats())
.recordLoad(event);
if (event.result() == LoadResult.LOAD_FAILED) {
LOG.debug("Track load failed for '{}' from {}: {}",
event.query(), event.source(), event.errorMessage());
}
}
/**
* Gets recent load events.
*
* @param limit maximum number of events to return
* @return array of recent load events
*/
public LoadEvent[] getRecentEvents(int limit) {
return loadEvents.stream()
.skip(Math.max(0, loadEvents.size() - limit))
.toArray(LoadEvent[]::new);
}
/**
* Gets load events for a specific source.
*
* @param source the source name
* @param limit maximum number of events to return
* @return array of load events for the source
*/
public LoadEvent[] getEventsForSource(String source, int limit) {
return loadEvents.stream()
.filter(e -> source.equalsIgnoreCase(e.source()))
.skip(Math.max(0, loadEvents.size() - limit))
.limit(limit)
.toArray(LoadEvent[]::new);
}
/**
* Gets statistics for a specific source.
*/
public SourceStats getSourceStats(String source) {
return sourceStats.get(source);
}
/**
* Gets all source names that have been tracked.
*/
public String[] getTrackedSources() {
return sourceStats.keySet().toArray(new String[0]);
}
/**
* Gets a snapshot of loading metrics.
*/
public LoadingSnapshot getSnapshot(int windowSeconds) {
long cutoff = System.currentTimeMillis() - (windowSeconds * 1000L);
LoadEvent[] recentEvents = loadEvents.stream()
.filter(e -> e.timestamp() >= cutoff)
.toArray(LoadEvent[]::new);
int success = 0, failed = 0, noMatch = 0;
long totalDuration = 0;
for (LoadEvent e : recentEvents) {
totalDuration += e.loadDurationMs();
switch (e.result()) {
case TRACK_LOADED, PLAYLIST_LOADED -> success++;
case LOAD_FAILED -> failed++;
case NO_MATCHES -> noMatch++;
}
}
double avgDuration = recentEvents.length > 0 ? (double) totalDuration / recentEvents.length : 0;
double successRate = recentEvents.length > 0 ? (success * 100.0) / recentEvents.length : 100;
// Calculate p95 load time
double p95Duration = 0;
if (recentEvents.length >= 5) {
long[] durations = Arrays.stream(recentEvents)
.mapToLong(LoadEvent::loadDurationMs)
.sorted()
.toArray();
int p95Index = (int) Math.ceil(durations.length * 0.95) - 1;
p95Index = Math.max(0, Math.min(p95Index, durations.length - 1));
p95Duration = durations[p95Index];
}
return new LoadingSnapshot(
windowSeconds,
totalLoads.get(),
successfulLoads.get(),
failedLoads.get(),
noMatchLoads.get(),
recentEvents.length,
success,
failed,
noMatch,
avgDuration,
p95Duration,
successRate,
recentEvents,
sourceStats.keySet().toArray(new String[0])
);
}
/**
* Clears all recorded events and statistics.
*/
public void clear() {
loadEvents.clear();
sourceStats.clear();
totalLoads.set(0);
successfulLoads.set(0);
failedLoads.set(0);
noMatchLoads.set(0);
}
/**
* Load result types.
*/
public enum LoadResult {
TRACK_LOADED,
PLAYLIST_LOADED,
NO_MATCHES,
LOAD_FAILED
}
/**
* Represents a single track load event.
*/
public record LoadEvent(
long timestamp,
String query,
String source,
LoadResult result,
long loadDurationMs,
String trackOrPlaylistName,
String errorMessage
) {
public boolean isSuccess() {
return result == LoadResult.TRACK_LOADED || result == LoadResult.PLAYLIST_LOADED;
}
}
/**
* Per-source statistics.
*/
public static class SourceStats {
private final AtomicLong totalLoads = new AtomicLong(0);
private final AtomicLong successfulLoads = new AtomicLong(0);
private final AtomicLong failedLoads = new AtomicLong(0);
private final AtomicLong totalDurationMs = new AtomicLong(0);
private volatile long maxDurationMs = 0;
private final ConcurrentLinkedDeque recentDurations = new ConcurrentLinkedDeque<>();
void recordLoad(LoadEvent event) {
totalLoads.incrementAndGet();
totalDurationMs.addAndGet(event.loadDurationMs());
if (event.loadDurationMs() > maxDurationMs) {
maxDurationMs = event.loadDurationMs();
}
if (event.isSuccess()) {
successfulLoads.incrementAndGet();
} else if (event.result() == LoadResult.LOAD_FAILED) {
failedLoads.incrementAndGet();
}
recentDurations.addLast(event.loadDurationMs());
while (recentDurations.size() > MAX_EVENTS_PER_SOURCE) {
recentDurations.pollFirst();
}
}
public long getTotalLoads() { return totalLoads.get(); }
public long getSuccessfulLoads() { return successfulLoads.get(); }
public long getFailedLoads() { return failedLoads.get(); }
public double getSuccessRate() {
long total = totalLoads.get();
return total > 0 ? (successfulLoads.get() * 100.0) / total : 100;
}
public double getAverageDurationMs() {
long total = totalLoads.get();
return total > 0 ? (double) totalDurationMs.get() / total : 0;
}
public long getMaxDurationMs() { return maxDurationMs; }
public double getP95DurationMs() {
Long[] durations = recentDurations.toArray(new Long[0]);
if (durations.length < 5) return getAverageDurationMs();
Arrays.sort(durations);
int p95Index = (int) Math.ceil(durations.length * 0.95) - 1;
p95Index = Math.max(0, Math.min(p95Index, durations.length - 1));
return durations[p95Index];
}
}
/**
* Snapshot of loading metrics for UI display.
*/
public record LoadingSnapshot(
int windowSeconds,
long totalLoadsAllTime,
long successfulLoadsAllTime,
long failedLoadsAllTime,
long noMatchLoadsAllTime,
int loadsInWindow,
int successInWindow,
int failedInWindow,
int noMatchInWindow,
double avgLoadDurationMs,
double p95LoadDurationMs,
double successRatePercent,
LoadEvent[] recentEvents,
String[] trackedSources
) {
public String formattedAvgDuration() {
if (avgLoadDurationMs >= 1000) {
return String.format("%.1fs", avgLoadDurationMs / 1000);
}
return String.format("%.0fms", avgLoadDurationMs);
}
}
}