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:
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user