Add configurable music search engine support

Introduced a configurable `MUSIC_SEARCH_ENGINE` option for determining the default search engine used for non-URL queries (YouTube, YouTube Music, or SoundCloud). Updated `SourceFinder` and related components to support this feature, enhanced query resolution logic, and improved flexibility in music searches. Added `SourceFinderTest` for thorough unit test coverage, ensuring correct behavior across search engines.
This commit is contained in:
anthorob committed 2026-10-02 10:52:08 -04:00
1 parent 22621e3912
commit 6d62a38b0b
6 files changed
+112 -8

No files matched your search

@@ -106,7 +106,10 @@ public class ConfigureCommand extends AdminCommand {
new SubcommandData("locale", "Preferred locale for the bot's replies on this server") new SubcommandData("locale", "Preferred locale for the bot's replies on this server")
.addOption(OptionType.STRING, "locale", "Locale code, e.g. en, fr", true), .addOption(OptionType.STRING, "locale", "Locale code, e.g. en, fr", true),
new SubcommandData("timezone", "Timezone used to display timestamps on this server") new SubcommandData("timezone", "Timezone used to display timestamps on this server")
.addOption(OptionType.STRING, "timezone", "Timezone id, e.g. UTC, Europe/Paris", true) .addOption(OptionType.STRING, "timezone", "Timezone id, e.g. UTC, Europe/Paris", true),
new SubcommandData("music-search-engine", "Default search engine used when a play query is not a direct url")
.addOptions(new OptionData(OptionType.STRING, "engine", "Search engine", true)
.addChoice("YouTube", "YOUTUBE").addChoice("YouTube Music", "YTMUSIC").addChoice("SoundCloud", "SOUNDCLOUD"))
); );
} }
@@ -143,6 +146,7 @@ public class ConfigureCommand extends AdminCommand {
case "log-level" -> configureStringSetting(context, settingService, GSettings.LOG_LEVEL, "level", "Log level reset to default", "Log level is now %s"); case "log-level" -> configureStringSetting(context, settingService, GSettings.LOG_LEVEL, "level", "Log level reset to default", "Log level is now %s");
case "locale" -> configureStringSetting(context, settingService, GSettings.LOCALE, "locale", "Locale reset to default", "Locale is now %s"); case "locale" -> configureStringSetting(context, settingService, GSettings.LOCALE, "locale", "Locale reset to default", "Locale is now %s");
case "timezone" -> configureStringSetting(context, settingService, GSettings.TIMEZONE, "timezone", "Timezone reset to default", "Timezone is now %s"); case "timezone" -> configureStringSetting(context, settingService, GSettings.TIMEZONE, "timezone", "Timezone reset to default", "Timezone is now %s");
case "music-search-engine" -> configureStringSetting(context, settingService, GSettings.MUSIC_SEARCH_ENGINE, "engine", "Default search engine reset to YouTube", "Default search engine is now %s");
case "show-all" -> showAll(context, settingService); case "show-all" -> showAll(context, settingService);
default -> CommandExecutionResult.error("Unknown subcommand: " + sub); default -> CommandExecutionResult.error("Unknown subcommand: " + sub);
}; };
@@ -39,7 +39,9 @@ public enum GSettings {
/** Preferred locale for the bot's replies on this guild. */ /** Preferred locale for the bot's replies on this guild. */
LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild", SettingType.STRING), LOCALE("LOCALE", "en", "Preferred locale for the bot's replies on this guild", SettingType.STRING),
/** Timezone used to display timestamps for this guild. */ /** Timezone used to display timestamps for this guild. */
TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild", SettingType.STRING); TIMEZONE("TIMEZONE", "UTC", "Timezone used to display timestamps for this guild", SettingType.STRING),
/** Default search engine used when a play query is not a direct url (YOUTUBE, YTMUSIC or SOUNDCLOUD). */
MUSIC_SEARCH_ENGINE("MUSIC_SEARCH_ENGINE", "YOUTUBE", "Default search engine used when a play query is not a direct url", SettingType.ENUM, "YOUTUBE", "YTMUSIC", "SOUNDCLOUD");
private final String name; private final String name;
private final String defaultValue; private final String defaultValue;
@@ -196,13 +196,14 @@ public class MusicManager implements MusicManagerCallback {
return; return;
} }
SourceFinder.AudioSourceType type = SourceFinder.getSource(query); SourceFinder.SearchEngine searchEngine = parseSearchEngine(guildService);
SourceFinder.AudioSourceType type = SourceFinder.getSource(query, searchEngine);
if (type == SourceFinder.AudioSourceType.Udio) { if (type == SourceFinder.AudioSourceType.Udio) {
context.reply("Udio playback is temporarily unsupported since the migration to Lavalink"); context.reply("Udio playback is temporarily unsupported since the migration to Lavalink");
return; return;
} }
String identifier = SourceFinder.getIdentifier(query); String identifier = SourceFinder.getIdentifier(query, searchEngine);
if (identifier == null) { if (identifier == null) {
context.reply("No source found to play the given url"); context.reply("No source found to play the given url");
return; return;
@@ -295,4 +296,14 @@ public class MusicManager implements MusicManagerCallback {
return DEFAULT_VOLUME; return DEFAULT_VOLUME;
} }
} }
private static SourceFinder.SearchEngine parseSearchEngine(GuildService guildService) {
String value = guildService.getGuildSettingService().getOrDefault(GSettings.MUSIC_SEARCH_ENGINE);
try {
return SourceFinder.SearchEngine.valueOf(value);
} catch (IllegalArgumentException e) {
logger.warn("Invalid MUSIC_SEARCH_ENGINE setting '{}', falling back to default", value, e);
return SourceFinder.SearchEngine.valueOf(GSettings.MUSIC_SEARCH_ENGINE.getDefaultValue());
}
}
} }
@@ -18,12 +18,24 @@ public class SourceFinder {
Bandcamp, Bandcamp,
Vimeo, Vimeo,
YTSearch, YTSearch,
YTMSearch,
SCSearch,
Udio, Udio,
DirectLinkMp3, DirectLinkMp3,
Nothing Nothing
} }
/**
* Default search engine used for non-url queries, configurable per guild via
* {@code GSettings.MUSIC_SEARCH_ENGINE}.
*/
public enum SearchEngine {
YOUTUBE,
YTMUSIC,
SOUNDCLOUD
}
private static final Pattern youtubePattern = Pattern.compile("^(?:https?://)?(?:www\\.)?(?:youtube\\.com|youtu\\.be)/.*?(?:embed|e|v|watch.*?v=)?/?([-_a-zA-Z0-9]{10,})?(?:&?index=\\d+)?(?>(?:playlist\\?|&)?list=([^#\\\\&?]{12,}))?", Pattern.CASE_INSENSITIVE); private static final Pattern youtubePattern = Pattern.compile("^(?:https?://)?(?:www\\.)?(?:youtube\\.com|youtu\\.be)/.*?(?:embed|e|v|watch.*?v=)?/?([-_a-zA-Z0-9]{10,})?(?:&?index=\\d+)?(?>(?:playlist\\?|&)?list=([^#\\\\&?]{12,}))?", Pattern.CASE_INSENSITIVE);
private static final Pattern youtubeMusicPattern = Pattern.compile("^(?:https?://)?(?:www\\.)?music\\.(?:youtube\\.com|youtu\\.be)/.*?(?:embed|e|v|watch.*?v=)?/?([-_a-zA-Z0-9]{10,})?(?:&?index=\\d+)?(?>(?:playlist\\?|&)?list=([^#\\\\&?]{12,}))?", Pattern.CASE_INSENSITIVE); private static final Pattern youtubeMusicPattern = Pattern.compile("^(?:https?://)?(?:www\\.)?music\\.(?:youtube\\.com|youtu\\.be)/.*?(?:embed|e|v|watch.*?v=)?/?([-_a-zA-Z0-9]{10,})?(?:&?index=\\d+)?(?>(?:playlist\\?|&)?list=([^#\\\\&?]{12,}))?", Pattern.CASE_INSENSITIVE);
private static final Pattern soundcloudPattern = Pattern.compile("^(?:https?://)?(?:www\\.)?(?:m\\.)?soundcloud\\.com/([a-zA-Z0-9-_]+)/([a-zA-Z0-9-_]+)(?:\\\\?.*|)$", Pattern.CASE_INSENSITIVE); private static final Pattern soundcloudPattern = Pattern.compile("^(?:https?://)?(?:www\\.)?(?:m\\.)?soundcloud\\.com/([a-zA-Z0-9-_]+)/([a-zA-Z0-9-_]+)(?:\\\\?.*|)$", Pattern.CASE_INSENSITIVE);
@@ -31,6 +43,14 @@ public class SourceFinder {
private static final Pattern mp3Pattern = Pattern.compile("^https?://(?:.*)(?:.mp3)$", Pattern.CASE_INSENSITIVE); private static final Pattern mp3Pattern = Pattern.compile("^https?://(?:.*)(?:.mp3)$", Pattern.CASE_INSENSITIVE);
public static AudioSourceType getSource(String url) { public static AudioSourceType getSource(String url) {
return getSource(url, SearchEngine.YOUTUBE);
}
/**
* Resolves the source type of the given query. For non-url queries, {@code searchEngine}
* selects which search source is used (YouTube, YouTube Music or SoundCloud).
*/
public static AudioSourceType getSource(String url, SearchEngine searchEngine) {
Matcher youtubeMatcher = youtubePattern.matcher(url); Matcher youtubeMatcher = youtubePattern.matcher(url);
Matcher youtubeMusicMatcher = youtubeMusicPattern.matcher(url); Matcher youtubeMusicMatcher = youtubeMusicPattern.matcher(url);
Matcher soundcloudMatcher = soundcloudPattern.matcher(url); Matcher soundcloudMatcher = soundcloudPattern.matcher(url);
@@ -58,18 +78,33 @@ public class SourceFinder {
} }
if (!(url.startsWith("http://") || url.startsWith("https://"))) { if (!(url.startsWith("http://") || url.startsWith("https://"))) {
return AudioSourceType.YTSearch; return switch (searchEngine) {
case YTMUSIC -> AudioSourceType.YTMSearch;
case SOUNDCLOUD -> AudioSourceType.SCSearch;
default -> AudioSourceType.YTSearch;
};
} }
return AudioSourceType.Nothing; return AudioSourceType.Nothing;
} }
public static String getIdentifier(String url) { public static String getIdentifier(String url) {
return getIdentifier(url, SearchEngine.YOUTUBE);
}
/**
* Builds the identifier sent to Lavalink. Non-url queries are prefixed with the search
* identifier matching {@code searchEngine} ({@code ytsearch:}, {@code ytmsearch:} or
* {@code scsearch:}).
*/
public static String getIdentifier(String url, SearchEngine searchEngine) {
String identifier; String identifier;
identifier = switch (getSource(url)) { identifier = switch (getSource(url, searchEngine)) {
case Youtube, YTMusic, Soundcloud, DirectLinkMp3 -> url; case Youtube, YTMusic, Soundcloud, DirectLinkMp3 -> url;
case YTSearch -> "ytsearch:" + url; case YTSearch -> "ytsearch:" + url;
case YTMSearch -> "ytmsearch:" + url;
case SCSearch -> "scsearch:" + url;
// Udio is not resolvable by the Lavalink node until the source is ported to a // Udio is not resolvable by the Lavalink node until the source is ported to a
// server-side plugin, see docs/reference/udio-lavalink-plugin. // server-side plugin, see docs/reference/udio-lavalink-plugin.
default -> null; default -> null;
@@ -60,7 +60,8 @@ public class MessageFunction {
String url = info.getIdentifier(); String url = info.getIdentifier();
String thumbnail = info.getArtworkUrl(); String thumbnail = info.getArtworkUrl();
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch || type == SourceFinder.AudioSourceType.YTMusic) { if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch
|| type == SourceFinder.AudioSourceType.YTMusic || type == SourceFinder.AudioSourceType.YTMSearch) {
url = String.format("https://youtu.be/%s", info.getIdentifier()); url = String.format("https://youtu.be/%s", info.getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", info.getIdentifier()); thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", info.getIdentifier());
} }
@@ -88,7 +89,7 @@ public class MessageFunction {
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier()); thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier());
} }
if (type == SourceFinder.AudioSourceType.YTMusic) { if (type == SourceFinder.AudioSourceType.YTMusic || type == SourceFinder.AudioSourceType.YTMSearch) {
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier()); thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier());
} }
@@ -0,0 +1,51 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class SourceFinderTest {
@Test
public void defaultSearchUsesYoutube() {
assertEquals(SourceFinder.AudioSourceType.YTSearch, SourceFinder.getSource("some song"));
assertEquals("ytsearch:some song", SourceFinder.getIdentifier("some song"));
}
@Test
public void youtubeSearchEngineProducesYtSearchPrefix() {
assertEquals(SourceFinder.AudioSourceType.YTSearch, SourceFinder.getSource("some song", SourceFinder.SearchEngine.YOUTUBE));
assertEquals("ytsearch:some song", SourceFinder.getIdentifier("some song", SourceFinder.SearchEngine.YOUTUBE));
}
@Test
public void ytMusicSearchEngineProducesYtmSearchPrefix() {
assertEquals(SourceFinder.AudioSourceType.YTMSearch, SourceFinder.getSource("some song", SourceFinder.SearchEngine.YTMUSIC));
assertEquals("ytmsearch:some song", SourceFinder.getIdentifier("some song", SourceFinder.SearchEngine.YTMUSIC));
}
@Test
public void soundcloudSearchEngineProducesScSearchPrefix() {
assertEquals(SourceFinder.AudioSourceType.SCSearch, SourceFinder.getSource("some song", SourceFinder.SearchEngine.SOUNDCLOUD));
assertEquals("scsearch:some song", SourceFinder.getIdentifier("some song", SourceFinder.SearchEngine.SOUNDCLOUD));
}
@Test
public void directUrlsIgnoreSearchEngine() {
String url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ";
assertEquals(SourceFinder.AudioSourceType.Youtube, SourceFinder.getSource(url, SourceFinder.SearchEngine.SOUNDCLOUD));
assertEquals(url, SourceFinder.getIdentifier(url, SourceFinder.SearchEngine.SOUNDCLOUD));
}
@Test
public void soundcloudUrlIsDetectedRegardlessOfSearchEngine() {
String url = "https://soundcloud.com/artist/track";
assertEquals(SourceFinder.AudioSourceType.Soundcloud, SourceFinder.getSource(url, SourceFinder.SearchEngine.YTMUSIC));
assertEquals(url, SourceFinder.getIdentifier(url, SourceFinder.SearchEngine.YTMUSIC));
}
}