Add music API endpoints and update OpenAPI responses

Introduced `/music/*` routes for managing music player actions via HTTP, including status, queue, skip, stop, volume control, and vote-skip functionality. Refactored settings and guild routes to use new `Response` DTOs for precise OpenAPI schema definitions. Updated `WebApi` to register music routes and ensure proper integration with the existing `MusicService` logic.
This commit is contained in:
anthorob committed 2026-09-30 05:20:35 -04:00
1 parent e9c6f403e5
commit 0f96051fd0
34 files changed
+762 -35

No files matched your search

@@ -161,7 +161,7 @@ public class App {
}, () -> { })); }, () -> { }));
// Web API (priority 70) — Lifecycle with graceful stop. // Web API (priority 70) — Lifecycle with graceful stop.
context.register(new WebApi(settings, guildServices, this::getJDA, this::shutdown)); context.register(new WebApi(settings, guildServices, music, this::getJDA, this::shutdown));
} }
private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) { private static Lifecycle lifecycle(int priority, ThrowingRunnable start, Runnable stop) {
@@ -13,6 +13,7 @@ import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi; import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent; import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiResponse; import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.http.server.dto.StatusResponse;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -47,7 +48,7 @@ public final class ActionRoutes {
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "Report the current Discord gateway health", summary = "Report the current Discord gateway health",
tags = {"Actions"}, tags = {"Actions"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)) responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = StatusResponse.class))
) )
private static void status(Supplier<JDA> jdaSupplier, Context ctx) { private static void status(Supplier<JDA> jdaSupplier, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier); JDA jda = GuildRoutes.jda(jdaSupplier);
@@ -14,6 +14,14 @@ import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi; import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent; import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiResponse; import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.http.server.dto.ChannelSummaryResponse;
import me.bobert.netneutrality.http.server.dto.ErrorResponse;
import me.bobert.netneutrality.http.server.dto.GuildOwnerEntryResponse;
import me.bobert.netneutrality.http.server.dto.GuildSummaryResponse;
import me.bobert.netneutrality.http.server.dto.MemberRolesResponse;
import me.bobert.netneutrality.http.server.dto.MemberSummaryResponse;
import me.bobert.netneutrality.http.server.dto.OwnerResponse;
import me.bobert.netneutrality.http.server.dto.VoiceChannelSummaryResponse;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member; import net.dv8tion.jda.api.entities.Member;
@@ -50,7 +58,7 @@ public final class GuildRoutes {
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "List every guild the bot is currently in", summary = "List every guild the bot is currently in",
tags = {"Guilds"}, tags = {"Guilds"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)) responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildSummaryResponse[].class))
) )
private static void guilds(Supplier<JDA> jdaSupplier, Context ctx) { private static void guilds(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json( ctx.json(
@@ -90,7 +98,7 @@ public final class GuildRoutes {
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "List every bot guild with its owner id", summary = "List every bot guild with its owner id",
tags = {"Guilds"}, tags = {"Guilds"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)) responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildOwnerEntryResponse[].class))
) )
private static void owner(Supplier<JDA> jdaSupplier, Context ctx) { private static void owner(Supplier<JDA> jdaSupplier, Context ctx) {
ctx.json( ctx.json(
@@ -107,8 +115,8 @@ public final class GuildRoutes {
tags = {"Guilds"}, tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = OwnerResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void ownerOf(Supplier<JDA> jdaSupplier, Context ctx) { private static void ownerOf(Supplier<JDA> jdaSupplier, Context ctx) {
@@ -129,8 +137,8 @@ public final class GuildRoutes {
tags = {"Guilds"}, tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = ChannelSummaryResponse[].class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void channels(Supplier<JDA> jdaSupplier, Context ctx) { private static void channels(Supplier<JDA> jdaSupplier, Context ctx) {
@@ -148,8 +156,8 @@ public final class GuildRoutes {
tags = {"Guilds"}, tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = ChannelSummaryResponse[].class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void roles(Supplier<JDA> jdaSupplier, Context ctx) { private static void roles(Supplier<JDA> jdaSupplier, Context ctx) {
@@ -170,8 +178,8 @@ public final class GuildRoutes {
@io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id") @io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
}, },
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = MemberSummaryResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void member(Supplier<JDA> jdaSupplier, Context ctx) { private static void member(Supplier<JDA> jdaSupplier, Context ctx) {
@@ -196,8 +204,8 @@ public final class GuildRoutes {
@io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id") @io.javalin.openapi.OpenApiParam(name = "userId", description = "The Discord user id")
}, },
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = MemberRolesResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void memberRoles(Supplier<JDA> jdaSupplier, Context ctx) { private static void memberRoles(Supplier<JDA> jdaSupplier, Context ctx) {
@@ -245,8 +253,8 @@ public final class GuildRoutes {
tags = {"Guilds"}, tags = {"Guilds"},
pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @io.javalin.openapi.OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = VoiceChannelSummaryResponse[].class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void voiceChannels(Supplier<JDA> jdaSupplier, Context ctx) { private static void voiceChannels(Supplier<JDA> jdaSupplier, Context ctx) {
@@ -267,8 +275,8 @@ public final class GuildRoutes {
@io.javalin.openapi.OpenApiParam(name = "channelId", description = "The Discord voice channel id") @io.javalin.openapi.OpenApiParam(name = "channelId", description = "The Discord voice channel id")
}, },
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = VoiceChannelSummaryResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void voiceChannel(Supplier<JDA> jdaSupplier, Context ctx) { private static void voiceChannel(Supplier<JDA> jdaSupplier, Context ctx) {
@@ -0,0 +1,250 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.player.Track;
import io.javalin.config.RoutesConfig;
import io.javalin.http.BadRequestResponse;
import io.javalin.http.Context;
import io.javalin.openapi.HttpMethod;
import io.javalin.openapi.OpenApi;
import io.javalin.openapi.OpenApiContent;
import io.javalin.openapi.OpenApiParam;
import io.javalin.openapi.OpenApiRequestBody;
import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.http.server.dto.CommandResultResponse;
import me.bobert.netneutrality.http.server.dto.ErrorResponse;
import me.bobert.netneutrality.http.server.dto.MusicStatusResponse;
import me.bobert.netneutrality.http.server.dto.QueuedTrackResponse;
import me.bobert.netneutrality.http.server.dto.VolumeRequest;
import me.bobert.netneutrality.http.server.dto.VolumeResponse;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.music.TrackInfo;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /music/*} endpoints on a {@link RoutesConfig} instance.
*
* <p>These expose the guild music player over HTTP, mirroring the slash commands. Vote-skipping
* in particular has no Lavalink API equivalent, so it is only reachable through
* {@link MusicManager#voteSkip(Member)}.
*/
public final class MusicRoutes {
private MusicRoutes() {
}
public static void register(RoutesConfig routes, Supplier<JDA> jdaSupplier, MusicService musicService) {
routes.get("/music/{guildId}/status", ctx -> status(jdaSupplier, musicService, ctx));
routes.get("/music/{guildId}/queue", ctx -> queue(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/skip", ctx -> skip(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/stop", ctx -> stop(jdaSupplier, musicService, ctx));
routes.put("/music/{guildId}/volume", ctx -> volume(jdaSupplier, musicService, ctx));
routes.post("/music/{guildId}/vote-skip/{userId}", ctx -> voteSkip(jdaSupplier, musicService, ctx));
}
@OpenApi(
path = "/music/{guildId}/status",
methods = HttpMethod.GET,
summary = "Get the current player status of a guild",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = MusicStatusResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void status(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
AudioChannel channel = manager.getConnectedChannel();
Map<String, Object> body = new LinkedHashMap<>();
body.put("connected", manager.isConnected());
body.put("channel_id", channel == null ? null : channel.getId());
body.put("channel_name", channel == null ? null : channel.getName());
body.put("playing", manager.isCurrentlyPlaying());
body.put("current_track", trackSummary(manager.getCurrentlyPlaying()));
body.put("position_ms", manager.getCurrentPosition());
body.put("queue_size", manager.getQueueSize());
ctx.json(body);
}
@OpenApi(
path = "/music/{guildId}/queue",
methods = HttpMethod.GET,
summary = "List the queued tracks of a guild",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = QueuedTrackResponse[].class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void queue(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
ctx.json(
manager.getQueueManager().getQueue().stream()
.map(MusicRoutes::trackInfoSummary)
.toList()
);
}
@OpenApi(
path = "/music/{guildId}/skip",
methods = HttpMethod.POST,
summary = "Force-skip the currently playing track",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = CommandResultResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void skip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
ctx.json(resultBody(manager.getQueueManager().skip()));
}
@OpenApi(
path = "/music/{guildId}/stop",
methods = HttpMethod.POST,
summary = "Stop the currently playing track, keeping the queue",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = CommandResultResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void stop(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
manager.stopTrack();
ctx.json(Map.of("status", "ok", "message", "Stopping current track"));
}
@OpenApi(
path = "/music/{guildId}/volume",
methods = HttpMethod.PUT,
summary = "Set the player volume of a guild",
tags = {"Music"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = VolumeRequest.class)),
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = VolumeResponse.class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void volume(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
MusicManager manager = manager(jdaSupplier, musicService, ctx);
int newVolume = volume(ctx);
manager.setVolume(newVolume);
ctx.json(Map.of("status", "ok", "volume", newVolume));
}
/**
* Reads and validates the {@code volume} field of the request body, failing with 400 when
* missing or out of the {@code [0, 100]} range.
*/
private static int volume(Context ctx) {
Object rawVolume = ctx.bodyAsClass(Map.class).get("volume");
int volume;
try {
volume = rawVolume instanceof Number number ? number.intValue() : Integer.parseInt(String.valueOf(rawVolume));
} catch (NumberFormatException e) {
throw new BadRequestResponse("volume must be an integer between 0 and 100");
}
if (volume < 0 || volume > 100) {
throw new BadRequestResponse("volume must be an integer between 0 and 100");
}
return volume;
}
@OpenApi(
path = "/music/{guildId}/vote-skip/{userId}",
methods = HttpMethod.POST,
summary = "Cast a vote to skip the currently playing track on behalf of a guild member",
tags = {"Music"},
pathParams = {
@OpenApiParam(name = "guildId", description = "The Discord guild id"),
@OpenApiParam(name = "userId", description = "The Discord user id casting the vote")
},
responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = CommandResultResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
}
)
private static void voteSkip(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
MusicManager manager = musicService.get(guild);
String userId = ctx.pathParam("userId");
CacheOrFetch.<Member>resolve(
ctx,
() -> guild.getMemberById(userId),
() -> guild.retrieveMemberById(userId),
member -> resultBody(manager.voteSkip(member))
);
}
/**
* Resolves the {@link MusicManager} for the guild path parameter, creating it on first use.
*/
private static MusicManager manager(Supplier<JDA> jdaSupplier, MusicService musicService, Context ctx) {
return musicService.get(GuildRoutes.guild(jdaSupplier, ctx));
}
/**
* Builds a JSON-friendly summary for a {@link CommandExecutionResult}.
*/
private static Map<String, Object> resultBody(CommandExecutionResult result) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("status", result.getResult().name());
body.put("message", result.getMessage());
return body;
}
/**
* Builds a JSON-friendly summary for the currently playing track, or {@code null} when idle.
*/
private static Map<String, Object> trackSummary(Track track) {
if (track == null) {
return null;
}
Map<String, Object> summary = new LinkedHashMap<>();
summary.put("title", track.getInfo().getTitle());
summary.put("author", track.getInfo().getAuthor());
summary.put("uri", track.getInfo().getUri());
summary.put("duration_ms", track.getInfo().getLength());
return summary;
}
/**
* Builds a JSON-friendly summary for a queued {@link TrackInfo}.
*/
private static Map<String, Object> trackInfoSummary(TrackInfo info) {
Map<String, Object> summary = trackSummary(info.getTrack());
summary.put("requester_id", info.getRequester().getId());
summary.put("skip_count", info.getSkipCount());
return summary;
}
}
@@ -19,6 +19,12 @@ import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.SettingType; import me.bobert.netneutrality.db.SettingType;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService; import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.http.server.dto.ErrorResponse;
import me.bobert.netneutrality.http.server.dto.SettingCatalogEntryResponse;
import me.bobert.netneutrality.http.server.dto.SettingsCurrentResponse;
import me.bobert.netneutrality.http.server.dto.SettingsReloadResponse;
import me.bobert.netneutrality.http.server.dto.SettingsUpdateRequest;
import me.bobert.netneutrality.http.server.dto.SettingsUpdateResponse;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
@@ -50,7 +56,7 @@ public final class SettingsRoutes {
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "List every known per-guild setting with its default value, description, and type", summary = "List every known per-guild setting with its default value, description, and type",
tags = {"Settings"}, tags = {"Settings"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map[].class)) responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = SettingCatalogEntryResponse[].class))
) )
private static void settingsCatalog(Context ctx) { private static void settingsCatalog(Context ctx) {
ctx.json( ctx.json(
@@ -77,8 +83,8 @@ public final class SettingsRoutes {
tags = {"Settings"}, tags = {"Settings"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = SettingsCurrentResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void settingsCurrent(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void settingsCurrent(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
@@ -97,11 +103,11 @@ public final class SettingsRoutes {
@OpenApiParam(name = "guildId", description = "The Discord guild id"), @OpenApiParam(name = "guildId", description = "The Discord guild id"),
@OpenApiParam(name = "name", description = "The setting name, as returned by /settings/catalog") @OpenApiParam(name = "name", description = "The setting name, as returned by /settings/catalog")
}, },
requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = Map.class)), requestBody = @OpenApiRequestBody(content = @OpenApiContent(from = SettingsUpdateRequest.class)),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = SettingsUpdateResponse.class)),
@OpenApiResponse(status = "400", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "400", content = @OpenApiContent(from = ErrorResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void settingsUpdate(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void settingsUpdate(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
@@ -185,8 +191,8 @@ public final class SettingsRoutes {
tags = {"Settings"}, tags = {"Settings"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = SettingsReloadResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void settingsReload(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void settingsReload(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
@@ -15,6 +15,9 @@ import io.javalin.openapi.OpenApiParam;
import io.javalin.openapi.OpenApiResponse; import io.javalin.openapi.OpenApiResponse;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.http.server.dto.BotStatsResponse;
import me.bobert.netneutrality.http.server.dto.ErrorResponse;
import me.bobert.netneutrality.http.server.dto.GuildStatsResponse;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
@@ -39,7 +42,7 @@ public final class StatsRoutes {
methods = HttpMethod.GET, methods = HttpMethod.GET,
summary = "Report bot-wide stats (guild count, gateway health, uptime)", summary = "Report bot-wide stats (guild count, gateway health, uptime)",
tags = {"Stats"}, tags = {"Stats"},
responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)) responses = @OpenApiResponse(status = "200", content = @OpenApiContent(from = BotStatsResponse.class))
) )
private static void bot(Supplier<JDA> jdaSupplier, Context ctx) { private static void bot(Supplier<JDA> jdaSupplier, Context ctx) {
JDA jda = GuildRoutes.jda(jdaSupplier); JDA jda = GuildRoutes.jda(jdaSupplier);
@@ -59,8 +62,8 @@ public final class StatsRoutes {
tags = {"Stats"}, tags = {"Stats"},
pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"), pathParams = @OpenApiParam(name = "guildId", description = "The Discord guild id"),
responses = { responses = {
@OpenApiResponse(status = "200", content = @OpenApiContent(from = Map.class)), @OpenApiResponse(status = "200", content = @OpenApiContent(from = GuildStatsResponse.class)),
@OpenApiResponse(status = "404", content = @OpenApiContent(from = Map.class)) @OpenApiResponse(status = "404", content = @OpenApiContent(from = ErrorResponse.class))
} }
) )
private static void guild(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) { private static void guild(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Context ctx) {
@@ -12,6 +12,7 @@ import io.javalin.openapi.plugin.OpenApiPlugin;
import io.javalin.openapi.plugin.swagger.SwaggerPlugin; import io.javalin.openapi.plugin.swagger.SwaggerPlugin;
import me.bobert.netneutrality.bootstrap.Lifecycle; import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
@@ -30,6 +31,7 @@ public class WebApi implements Lifecycle {
private final SettingsProvider settings; private final SettingsProvider settings;
private final GuildServiceProvider guildServices; private final GuildServiceProvider guildServices;
private final MusicService musicService;
private final Supplier<JDA> jdaSupplier; private final Supplier<JDA> jdaSupplier;
private final Runnable shutdownHook; private final Runnable shutdownHook;
private Javalin app; private Javalin app;
@@ -37,11 +39,13 @@ public class WebApi implements Lifecycle {
public WebApi( public WebApi(
SettingsProvider settings, SettingsProvider settings,
GuildServiceProvider guildServices, GuildServiceProvider guildServices,
MusicService musicService,
Supplier<JDA> jdaSupplier, Supplier<JDA> jdaSupplier,
Runnable shutdownHook Runnable shutdownHook
) { ) {
this.settings = settings; this.settings = settings;
this.guildServices = guildServices; this.guildServices = guildServices;
this.musicService = musicService;
this.jdaSupplier = jdaSupplier; this.jdaSupplier = jdaSupplier;
this.shutdownHook = shutdownHook; this.shutdownHook = shutdownHook;
} }
@@ -77,6 +81,7 @@ public class WebApi implements Lifecycle {
ActionRoutes.register(config.routes, jdaSupplier, shutdownHook); ActionRoutes.register(config.routes, jdaSupplier, shutdownHook);
SettingsRoutes.register(config.routes, jdaSupplier, guildServices); SettingsRoutes.register(config.routes, jdaSupplier, guildServices);
StatsRoutes.register(config.routes, jdaSupplier, guildServices); StatsRoutes.register(config.routes, jdaSupplier, guildServices);
MusicRoutes.register(config.routes, jdaSupplier, musicService);
config.routes.exception(HttpResponseException.class, (e, ctx) -> config.routes.exception(HttpResponseException.class, (e, ctx) ->
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage())) ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage()))
@@ -0,0 +1,18 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /stats/bot} response.
*/
public record BotStatsResponse(
int guild_count,
long gateway_ping,
String status,
long uptime_seconds
) {
}
@@ -0,0 +1,17 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a {@code /guilds/channels/{guildId}} and {@code /guilds/roles/{guildId}}
* entry.
*/
public record ChannelSummaryResponse(
String channel_id,
String channel_name
) {
}
@@ -0,0 +1,17 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/skip}, {@code /music/{guildId}/stop}, and
* {@code /music/{guildId}/vote-skip/{userId}} responses.
*/
public record CommandResultResponse(
String status,
String message
) {
}
@@ -0,0 +1,17 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* Shared OpenAPI schema for the error body written by the {@code HttpResponseException} handler
* in {@link me.bobert.netneutrality.http.server.WebApi}.
*/
public record ErrorResponse(
String status,
String message
) {
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a {@code /guilds/owner} entry.
*/
public record GuildOwnerEntryResponse(
String guild_id,
String owner_id
) {
}
@@ -0,0 +1,20 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /stats/guilds/{guildId}} response.
*/
public record GuildStatsResponse(
String guild_id,
int member_count,
int channel_count,
int role_count,
boolean debug_enabled,
int blacklist_size
) {
}
@@ -0,0 +1,19 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a {@code /guilds} entry.
*/
public record GuildSummaryResponse(
String guild_id,
String name,
String icon_url,
int member_count,
String owner_id
) {
}
@@ -0,0 +1,17 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}/roles} response.
*/
public record MemberRolesResponse(
String guild_id,
String user_id,
RoleSummaryResponse[] roles
) {
}
@@ -0,0 +1,18 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /guilds/members/{guildId}/{userId}} response.
*/
public record MemberSummaryResponse(
String user_id,
String username,
String nickname,
String[] role_ids
) {
}
@@ -0,0 +1,21 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/status} response.
*/
public record MusicStatusResponse(
boolean connected,
String channel_id,
String channel_name,
boolean playing,
TrackSummaryResponse current_track,
long position_ms,
int queue_size
) {
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /guilds/owner/{guildId}} response.
*/
public record OwnerResponse(
String guild_id,
String owner_id
) {
}
@@ -0,0 +1,20 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a {@code /music/{guildId}/queue} entry.
*/
public record QueuedTrackResponse(
String title,
String author,
String uri,
long duration_ms,
String requester_id,
int skip_count
) {
}
@@ -0,0 +1,17 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a role entry of the {@code /guilds/members/{guildId}/{userId}/roles}
* response.
*/
public record RoleSummaryResponse(
String role_id,
String role_name
) {
}
@@ -0,0 +1,19 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a {@code /settings/catalog} entry.
*/
public record SettingCatalogEntryResponse(
String name,
String default_value,
String description,
String type,
String[] allowed_values
) {
}
@@ -0,0 +1,18 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.Map;
/**
* OpenAPI schema for the {@code /settings/current/{guildId}} response.
*/
public record SettingsCurrentResponse(
String status,
Map<String, String> settings
) {
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /settings/reload/{guildId}} response.
*/
public record SettingsReloadResponse(
boolean status
) {
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /settings/current/{guildId}/{name}} request body.
*/
public record SettingsUpdateRequest(
String value
) {
}
@@ -0,0 +1,17 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /settings/current/{guildId}/{name}} response.
*/
public record SettingsUpdateResponse(
String status,
String name,
String value
) {
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /actions/status} response.
*/
public record StatusResponse(
String health,
String ping
) {
}
@@ -0,0 +1,19 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the currently playing track summary embedded in
* {@code /music/{guildId}/status}.
*/
public record TrackSummaryResponse(
String title,
String author,
String uri,
long duration_ms
) {
}
@@ -0,0 +1,20 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /guilds/voice-channels/{guildId}} and
* {@code /guilds/voice-channels/{guildId}/{channelId}} responses.
*/
public record VoiceChannelSummaryResponse(
String channel_id,
String channel_name,
int user_limit,
int member_count,
VoiceMemberSummaryResponse[] members
) {
}
@@ -0,0 +1,17 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for a member entry connected to a voice channel.
*/
public record VoiceMemberSummaryResponse(
String user_id,
String username,
String nickname
) {
}
@@ -0,0 +1,15 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/volume} request body.
*/
public record VolumeRequest(
int volume
) {
}
@@ -0,0 +1,16 @@
package me.bobert.netneutrality.http.server.dto;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
/**
* OpenAPI schema for the {@code /music/{guildId}/volume} response.
*/
public record VolumeResponse(
String status,
int volume
) {
}
@@ -20,6 +20,7 @@ import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.utils.MusicUtils; import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.GuildVoiceState; import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -75,6 +76,18 @@ public class MusicManager implements MusicManagerCallback {
return scheduler.getQueueManager(); return scheduler.getQueueManager();
} }
/**
* Returns the vote-skip service for this guild.
*
* <p>Exposed separately since vote-skipping is application logic that has no equivalent on
* the Lavalink API.
*
* @return the vote-skip service
*/
public VoteSkipService getVoteSkipService() {
return voteSkipService;
}
public Guild getGuild() { public Guild getGuild() {
return guild; return guild;
} }
@@ -213,7 +226,7 @@ public class MusicManager implements MusicManagerCallback {
} }
public synchronized void stopTrack() { public synchronized void stopTrack() {
updatePlayer("stop track", builder -> builder.stopTrack()); updatePlayer("stop track", PlayerUpdateBuilder::stopTrack);
} }
public synchronized CommandExecutionResult resume() { public synchronized CommandExecutionResult resume() {
@@ -221,8 +234,16 @@ public class MusicManager implements MusicManagerCallback {
} }
public synchronized CommandExecutionResult voteSkip(CommandContext context) { public synchronized CommandExecutionResult voteSkip(CommandContext context) {
return voteSkip(context.getGuildUser());
}
/**
* Same as {@link #voteSkip(CommandContext)} but driven directly by a {@link Member}, so it
* can be invoked from non-command callers such as the REST API.
*/
public synchronized CommandExecutionResult voteSkip(Member member) {
int threshold = parseVoteSkipThreshold(guildService); int threshold = parseVoteSkipThreshold(guildService);
return voteSkipService.voteSkip(context, getQueueManager(), getConnectedChannel(), isCurrentlyPlaying(), threshold); return voteSkipService.voteSkip(member, guild.getId(), getQueueManager(), getConnectedChannel(), isCurrentlyPlaying(), threshold);
} }
private static int parseVoteSkipThreshold(GuildService guildService) { private static int parseVoteSkipThreshold(GuildService guildService) {
@@ -8,6 +8,7 @@ package me.bobert.netneutrality.music;
import me.bobert.netneutrality.core.command.CommandExecutionResult; import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.context.CommandContext; import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel; import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
@@ -37,6 +38,15 @@ public class VoteSkipService {
* configurable vote threshold percentage of connected listeners. * configurable vote threshold percentage of connected listeners.
*/ */
public CommandExecutionResult voteSkip(CommandContext context, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying, int thresholdPercent) { public CommandExecutionResult voteSkip(CommandContext context, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying, int thresholdPercent) {
return voteSkip(context.getGuildUser(), context.getGuild().getId(), queueManager, audioChannel, isCurrentlyPlaying, thresholdPercent);
}
/**
* Same as {@link #voteSkip(CommandContext, QueueManager, AudioChannel, boolean, int)} but
* driven directly by a {@link Member}, so it can be invoked from non-command callers such as
* the REST API, which has no {@link CommandContext} to work with.
*/
public CommandExecutionResult voteSkip(Member voter, String guildId, QueueManager queueManager, AudioChannel audioChannel, boolean isCurrentlyPlaying, int thresholdPercent) {
if (audioChannel == null) { if (audioChannel == null) {
return CommandExecutionResult.warn("Bot is not connected to a voice channel"); return CommandExecutionResult.warn("Bot is not connected to a voice channel");
} }
@@ -60,8 +70,8 @@ public class VoteSkipService {
double needed = connected * (thresholdPercent / 100.0); double needed = connected * (thresholdPercent / 100.0);
info.addSkip(context.getGuildUser()); info.addSkip(voter);
logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", context.getGuild().getId(), connected, needed, info.getSkipCount(), context.getGuildUser().getId()); logger.debug("VoteSkip: guild={}, connected={}, needed={}, skips={}, user={}", guildId, connected, needed, info.getSkipCount(), voter.getId());
if (info.getSkipCount() > needed) { if (info.getSkipCount() > needed) {
return queueManager.skip(); return queueManager.skip();
@@ -10,6 +10,7 @@ import me.bobert.netneutrality.db.service.BlacklistService;
import me.bobert.netneutrality.db.service.GuildService; import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider; import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService; import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.music.MusicService;
import me.bobert.netneutrality.utils.SettingsProvider; import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA; import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild; import net.dv8tion.jda.api.entities.Guild;
@@ -82,7 +83,9 @@ class WebApiTest {
when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port)); when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port));
when(settings.get("NETNEUTRALITY_API_KEY")).thenReturn(API_KEY); when(settings.get("NETNEUTRALITY_API_KEY")).thenReturn(API_KEY);
webApi = new WebApi(settings, provider, () -> jda, () -> { }); MusicService musicService = mock(MusicService.class);
webApi = new WebApi(settings, provider, musicService, () -> jda, () -> { });
webApi.start(); webApi.start();
} }