diff --git a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java index c71fed2..fc2577c 100644 --- a/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java +++ b/src/main/java/me/bobert/netneutrality/command/bot/ConfigureCommand.java @@ -106,7 +106,10 @@ public class ConfigureCommand extends AdminCommand { new SubcommandData("locale", "Preferred locale for the bot's replies on this server") .addOption(OptionType.STRING, "locale", "Locale code, e.g. en, fr", true), 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 "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 "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); default -> CommandExecutionResult.error("Unknown subcommand: " + sub); }; diff --git a/src/main/java/me/bobert/netneutrality/db/GSettings.java b/src/main/java/me/bobert/netneutrality/db/GSettings.java index 68482ad..c99145d 100644 --- a/src/main/java/me/bobert/netneutrality/db/GSettings.java +++ b/src/main/java/me/bobert/netneutrality/db/GSettings.java @@ -39,7 +39,9 @@ public enum GSettings { /** 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), /** 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 defaultValue; diff --git a/src/main/java/me/bobert/netneutrality/music/MusicManager.java b/src/main/java/me/bobert/netneutrality/music/MusicManager.java index d3b90dd..6458667 100644 --- a/src/main/java/me/bobert/netneutrality/music/MusicManager.java +++ b/src/main/java/me/bobert/netneutrality/music/MusicManager.java @@ -196,13 +196,14 @@ public class MusicManager implements MusicManagerCallback { return; } - SourceFinder.AudioSourceType type = SourceFinder.getSource(query); + SourceFinder.SearchEngine searchEngine = parseSearchEngine(guildService); + SourceFinder.AudioSourceType type = SourceFinder.getSource(query, searchEngine); if (type == SourceFinder.AudioSourceType.Udio) { context.reply("Udio playback is temporarily unsupported since the migration to Lavalink"); return; } - String identifier = SourceFinder.getIdentifier(query); + String identifier = SourceFinder.getIdentifier(query, searchEngine); if (identifier == null) { context.reply("No source found to play the given url"); return; @@ -295,4 +296,14 @@ public class MusicManager implements MusicManagerCallback { 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()); + } + } } diff --git a/src/main/java/me/bobert/netneutrality/music/SourceFinder.java b/src/main/java/me/bobert/netneutrality/music/SourceFinder.java index 13b55c5..0015997 100644 --- a/src/main/java/me/bobert/netneutrality/music/SourceFinder.java +++ b/src/main/java/me/bobert/netneutrality/music/SourceFinder.java @@ -18,12 +18,24 @@ public class SourceFinder { Bandcamp, Vimeo, YTSearch, + YTMSearch, + SCSearch, Udio, DirectLinkMp3, 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 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); @@ -31,6 +43,14 @@ public class SourceFinder { private static final Pattern mp3Pattern = Pattern.compile("^https?://(?:.*)(?:.mp3)$", Pattern.CASE_INSENSITIVE); 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 youtubeMusicMatcher = youtubeMusicPattern.matcher(url); Matcher soundcloudMatcher = soundcloudPattern.matcher(url); @@ -58,18 +78,33 @@ public class SourceFinder { } 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; } 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; - identifier = switch (getSource(url)) { + identifier = switch (getSource(url, searchEngine)) { case Youtube, YTMusic, Soundcloud, DirectLinkMp3 -> 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 // server-side plugin, see docs/reference/udio-lavalink-plugin. default -> null; diff --git a/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java b/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java index ad540c0..3b2490e 100644 --- a/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java +++ b/src/main/java/me/bobert/netneutrality/utils/MessageFunction.java @@ -60,7 +60,8 @@ public class MessageFunction { String url = info.getIdentifier(); 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()); 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()); } - 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()); } diff --git a/src/test/java/me/bobert/netneutrality/music/SourceFinderTest.java b/src/test/java/me/bobert/netneutrality/music/SourceFinderTest.java new file mode 100644 index 0000000..855c3b0 --- /dev/null +++ b/src/test/java/me/bobert/netneutrality/music/SourceFinderTest.java @@ -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)); + } +}