Compare commits

...
3 Commits
Author SHA1 Message Date
anthorob 4cf7c47645 Remove Lavaplayer and migrate to Lavalink
Replaced the bot's audio handling system by removing Lavaplayer dependencies and migrating to a Lavalink v4 node. Updated `MusicManager`, `TrackScheduler`, and related classes to utilize the `LavalinkClient`. Refactored playlist and track loading logic, task integrations, and event dispatch to integrate with the remote node. Deprecated the custom Udio source, preserving references for a future plugin port. Documented configuration and deployment details for the new audio architecture.
2026-09-23 15:08:06 -04:00
anthorob 01bfe22d22 Migrate HTTP server to Javalin and remove legacy handlers
Replaced the existing HttpServer implementation and associated handler classes (`AbstractHandler`, `ActionHandler`, `GuildHandler`, `RootHandler`) with a Javalin-based routing system. Improved modularity by introducing `ActionRoutes` and `GuildRoutes` for route definitions, and centralized JSON mapping using `GsonJsonMapper`. Simplified caching via the new `ResponseCache` utility, updated `WebModule` for cleaner timeout handling, and extended test coverage for API endpoints.
2026-09-23 12:07:38 -04:00
anthorob 5f6265db01 Improve task lifecycle handling and optimize TaskManagerImpl
Redesigned task lifecycle management to properly cascade parent-child removals and synchronize database persistence on task deletion. Refactored task management to address memory leaks, concurrency collisions, and pending-task cleanup issues. Enhanced lookup performance, logging accuracy, and test coverage with a comprehensive unit test suite validating all task scenarios.
2026-09-23 10:07:26 -04:00
50 changed files with 2757 additions and 887 deletions

No files matched your search

+7 -4
View File
@@ -1,5 +1,4 @@
NETNEUTRALITY_TAG=latest
NETNEUTRALITY_BOT_ENV=dev
NETNEUTRALITY_BOT_TOKEN=
NETNEUTRALITY_BOT_PREFIX=$
@@ -12,13 +11,17 @@ MYSQL_PORT=3306
MYSQL_USER=java
MYSQL_PASSWORD=password
MYSQL_DATABASE=netneutrality
MYSQL_ROOT_PASSWORD=password
MARIADB_AUTO_UPGRADE=1
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=dev-player
NETNEUTRALITY_PO_TOKEN=
NETNEUTRALITY_VISITOR_DATA=
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
NETNEUTRALITY_OAUTH_TOKEN=
NETNEUTRALITY_CIPHER_URL=http://ejs-api:8001
NETNEUTRALITY_CIPHER_PASSWORD=
NETNEUTRALITY_WEBPO_URL=http://webpo:8080
NETNEUTRALITY_WEBPO_PASSWORD=
NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST=
+213
View File
@@ -0,0 +1,213 @@
---
sessionId: session-260919-132150-1vmv
---
# Requirements
### Overview & Goals
The `TaskManager` subsystem in Net Neutrality manages the in-memory registry, execution dispatch, locking, persistence, and lifecycle transitions for Discord interaction tasks (buttons, modals, reactions, step-by-step flows, and scheduled background tasks).
This plan addresses several bugs, synchronization oversights, memory leak potentials, and architectural discrepancies between `docs/tasks.md` and `TaskManagerImpl.java`:
1. **Fix Parent-Child Lifecycle Propagation**: Correctly handle `REMOVE_SELF_AND_CHILDREN` when triggered by child tasks (`WithParent`).
2. **Synchronize DB Persistence on Removal**: Ensure `PersistableTask` entries are purged from the database via `TaskService.unpersistTask()` when removed.
3. **Prevent Memory Leaks in Pending Tasks**: Unify `pendingTasks` tracking and ensure `TaskCleanup` cleans up expired pending step tasks.
4. **Fix Concurrency & Lock Cleanup Hazards**: Eliminate race conditions where lock eviction during task execution allows duplicate concurrent runs.
5. **Optimize Lookup Performance**: Replace eager full-collection queries with short-circuiting streams for partial ID lookups.
6. **Improve Test Coverage**: Introduce thorough unit tests covering all task lifecycle paths, locking behavior, and persistence hooks.
### Scope
- **In Scope**:
- `me.bobert.netneutrality.core.tasks.TaskManager` interface.
- `me.bobert.netneutrality.core.tasks.TaskManagerImpl` implementation.
- `me.bobert.netneutrality.scheduled.cleanup.TaskCleanup` expiration handling.
- Interaction deletion error-handling callbacks on `InteractionHook`.
- Comprehensive unit test suite for `TaskManagerImpl`.
- **Out of Scope**:
- Refactoring concrete task business logic (e.g., individual music or reaction commands).
- Schema changes to the `OTask` entity or database migrations.
### Functional Requirements
- **FR-1**: When any child task implementing `WithParent` returns `Lifecycle.REMOVE_SELF_AND_CHILDREN`, `TaskManager` must remove the parent task and all associated child tasks from the registry.
- **FR-2**: When any `PersistableTask` is removed from `TaskManager` (via lifecycle completion, manual removal, or cancellation), `TaskManager` must call `taskService.unpersistTask()` so the task is deleted from persistent storage.
- **FR-3**: Pending step tasks must be tracked reliably, exposed for expiration checks, and cleaned up by `TaskCleanup` if abandoned.
- **FR-4**: Concurrent task invocations sharing the same `lockId` must be serialized safely without lock collisions or premature lock disposal.
- **FR-5**: `findTaskByPartialId` must short-circuit upon finding the first match without allocating full intermediate lists.
- **FR-6**: Interaction deletion via `Deletable` must gracefully handle expired hooks and Discord API errors asynchronously.
# Technical Design
### Current Implementation
`TaskManagerImpl` (`me.bobert.netneutrality.core.tasks.TaskManagerImpl`) implements `TaskManager` and `Lifecycle`. It maintains:
- `tasks`: `ConcurrentHashMap<String, Task>` for active tasks.
- `pendingTasks`: `CopyOnWriteArrayList<Task>` for unassigned step tasks.
- `deletableTasksContext`: `ConcurrentHashMap<String, CommandContext>` for tracking interaction hooks to delete.
- `tasksLock`: `ConcurrentHashMap<String, ReentrantLock>` for per-task/per-parent synchronization.
- `taskService`: `TaskService` for loading and persisting `PersistableTask` entities.
### Key Issues & Decisions
#### 1. Parent/Child Task Lifecycle Resolution
- **Issue**: `handleTaskLifecycle` only checks `if (task instanceof WithChildren)` for `REMOVE_SELF_AND_CHILDREN`. When a button task (which implements `WithParent<P>`, such as `LoadTrackFromPlaylistTask`) returns `REMOVE_SELF_AND_CHILDREN`, the check evaluates to `false`, leaving the parent and sibling buttons active in memory.
- **Decision**: In `handleTaskLifecycle`, check if the task implements `WithParent<?>`. If so, obtain the parent task (`taskWithParent.getParentTask()`) and remove the parent along with all of its child tasks (`parent.getChildTasks()`). If the task directly implements `WithChildren`, remove all its child tasks and itself.
#### 2. Automatic Persistence Synchronization
- **Issue**: `docs/tasks.md` specifies that persistable tasks removed after execution are deleted from the database. Currently `TaskManagerImpl.removeTask` only removes from `tasks` map and never calls `taskService.unpersistTask(persistableTask)`.
- **Decision**: In `removeTask(Task task)`, check `if (task instanceof PersistableTask persistableTask)` and invoke `taskService.unpersistTask(persistableTask)`.
#### 3. Pending Task Registry & Cleanup
- **Issue**: `pendingTasks` is stored in a `CopyOnWriteArrayList<Task>`. `getTasks()` ignores it, preventing `TaskCleanup` from pruning expired step tasks. `removeTask()` also ignores `pendingTasks`.
- **Decision**: Store `pendingTasks` in a `ConcurrentHashMap<String, Task>` (or unify with active tasks / pending lookup). Update `getTasks()` or provide a method to include pending tasks in cleanup routines. Update `removeTask(Task task)` to remove from `pendingTasks` as well as `tasks`.
#### 4. Concurrency & Lock Management
- **Issue**: `removeTask` calls `tasksLock.remove(task.getLockId())` immediately. If another thread is waiting or about to acquire the lock for a sibling task, it can get a new lock instance from `computeIfAbsent`, breaking mutual exclusion.
- **Decision**: Maintain lock instances safely; do not remove locks eagerly while in the middle of executing handlers. Persistable tasks with state should also participate in locking if needed, or maintain clear locking semantics.
#### 5. Lookup API Optimization
- **Issue**: `findTaskByPartialId` calls `findTasksByPartialId`, instantiating and populating a full `ArrayList` before taking the first element.
- **Decision**: Implement `findTaskByPartialId` with `.filter(...).findFirst()` directly on the stream.
### Architecture & Component Interaction Diagram
```mermaid
graph TD
subgraph Discord Events
BE[ButtonInteractionEvent] --> EH[EventsHandler]
ME[ModalInteractionEvent] --> EH
RE[MessageReactionAddEvent] --> EH
MSG[MessageReceivedEvent] --> EH
end
subgraph TaskManager Subsystem
EH -->|TaskContext| TM[TaskManagerImpl]
TM -->|Acquire Lock| TL[tasksLock Map]
TM -->|Lookup Task| TR[tasks & pendingTasks Maps]
TM -->|Execute| T[Task.execute]
T -->|Return Lifecycle| TM
TM -->|Cascade Cleanup| LC[handleTaskLifecycle]
LC -->|WithParent / WithChildren| RM[removeTask]
RM -->|Unpersist| TS[TaskService]
TS -->|Delete| DB[(Database / OTask)]
RM -->|Deletable| DH[InteractionHook.deleteOriginal]
end
subgraph Background Maintenance
TC[TaskCleanup] -->|Periodic Poll| TM
TM -->|Prune Expired| RM
end
```
### Proposed Code Changes & Contracts
#### `TaskManagerImpl.java`
```java
private void handleTaskLifecycle(Task task, Task.Lifecycle status) {
if (status == null) {
throw new IllegalArgumentException("Task lifecycle status cannot be null");
}
switch (status) {
case REMOVE_SELF -> removeTask(task);
case REMOVE_SELF_AND_CHILDREN -> {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent instanceof WithChildren parentWithChildren) {
for (Task child : parentWithChildren.getChildTasks()) {
removeTask(child);
}
}
removeTask(parent);
} else if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
removeTask(child);
}
removeTask(task);
} else {
removeTask(task);
}
}
case KEEP, PARENT, BUILTIN -> {
// Maintained in active registry
}
}
}
@Override
public void removeTask(Task task) {
if (task == null) return;
String id = task.getId();
boolean removed = (tasks.remove(id) != null);
removed |= (pendingTasks.remove(task) != null);
if (removed) {
logger.debug("Removing task: {}", id);
if (task instanceof PersistableTask persistableTask) {
taskService.unpersistTask(persistableTask);
}
}
}
```
### File Structure Changes
- **Modified**: `src/main/java/me/bobert/netneutrality/core/tasks/TaskManagerImpl.java`
- **Modified**: `src/main/java/me/bobert/netneutrality/scheduled/cleanup/TaskCleanup.java`
- **Added**: `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java`
# Testing
### Validation Approach
Automated unit tests will be created in `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` utilizing JUnit 5, AssertJ, and Mockito to validate all functional requirements without requiring live Discord connections or database instances.
### Key Scenarios
1. **Task Registration & Lookup**:
- Insert standard `Task`, verify `getTask(id)` and `hasTask(id)` return `true`.
- Insert duplicate task ID, verify rejected with user notification.
2. **Parent / Child Lifecycle (`REMOVE_SELF_AND_CHILDREN`)**:
- Create parent task implementing `WithChildren` and child task implementing `WithParent`.
- Execute child task returning `REMOVE_SELF_AND_CHILDREN`.
- Verify parent task and all sibling child tasks are removed from the registry.
3. **Persistable Task DB Synchronization**:
- Insert `PersistableTask`, verify `TaskService.persistTask` is called.
- Remove `PersistableTask` via lifecycle completion or `removeTask()`, verify `TaskService.unpersistTask` is invoked.
4. **Step Task Flow**:
- Insert `StepTask` into pending registry.
- Reassign ID via `activateTask(task, oldId)`, verify task moves from pending to active registry under new ID.
5. **Partial ID Search**:
- Query `findTaskByPartialId` and verify it matches correct instance without scanning beyond first match.
6. **Task Expiration & Cleanup**:
- Mock expired active and pending tasks, trigger `TaskCleanup.run()`, verify expired tasks are pruned from both collections.
7. **Concurrency & Locking**:
- Simulate concurrent executions for tasks sharing a `lockId`, confirming mutual exclusion.
# Delivery Steps
### ✓ Step 1: Fix Task Lifecycle Hierarchy and DB Persistence Synchronization
`TaskManagerImpl` correctly propagates parent-child lifecycle actions and synchronizes task deletions with `TaskService`.
- Update `handleTaskLifecycle` in `TaskManagerImpl.java` to support `REMOVE_SELF_AND_CHILDREN` for tasks implementing `WithParent`, resolving the parent task and removing both the parent and all sibling child tasks.
- Modify `removeTask(Task task)` in `TaskManagerImpl.java` to invoke `taskService.unpersistTask(persistableTask)` when `task instanceof PersistableTask`, ensuring persisted records in the database are purged when tasks finish.
- Refactor `deleteInteraction(Task task)` in `TaskManagerImpl.java` to cleanly handle `Deletable` logic for both standalone and parented tasks, adding failure logging to `InteractionHook.deleteOriginal().queue()`.
### ✓ Step 2: Refactor Pending Task Management and Concurrency Locking
Pending tasks and active task locks are managed safely without race conditions or memory leaks.
- Refactor `pendingTasks` in `TaskManagerImpl.java` to use a `ConcurrentHashMap<String, Task>` keyed by task ID for O(1) lookups and consistent state management.
- Update `removeTask(Task task)`, `getTask(String id)`, and `getTasks()` in `TaskManagerImpl.java` to include pending tasks so they can be retrieved, removed, and cleaned up when expired.
- Update `TaskCleanup.java` to ensure expired pending tasks are identified and cleaned up.
- Improve lock management in `TaskManagerImpl.handle(TaskContext context)` so that lock eviction does not interfere with concurrently waiting threads acquiring new lock instances.
### ✓ Step 3: Optimize Task Query Performance and Diagnostic Logging
Task lookup methods use short-circuiting stream pipelines, and diagnostic logging is corrected.
- Optimize `findTaskByPartialId` in `TaskManagerImpl.java` to use `.findFirst()` directly rather than allocating a full list via `findTasksByPartialId`.
- Fix typos in log messages and warnings across `TaskManagerImpl.java` (e.g. `"Aquiring lock"`, `"childs"`).
- Ensure consistent ephemeral fallback messages and handling when unacknowledged interactions or missing tasks are encountered.
### ✓ Step 4: Add Comprehensive Unit Tests for Task Management
A complete unit test suite validates `TaskManagerImpl` functionality across all task types, lifecycles, and concurrency scenarios.
- Create `src/test/java/me/bobert/netneutrality/core/tasks/TaskManagerImplTest.java` using JUnit 5 and Mockito.
- Implement tests for standard task registration (`insert`), lookup (`getTask`, `findTaskByPartialId`), and removal (`removeTask`).
- Implement tests for parent-child tasks (`WithChildren`, `WithParent`), verifying lock inheritance and `REMOVE_SELF_AND_CHILDREN` cascading removals.
- Implement tests for `StepTask` lifecycle (pending insertion, ID reassignment via `activateTask`, and expiration).
- Implement tests for `PersistableTask` lifecycle, verifying automatic database persistence on insert and unpersisting on removal.
- Implement tests verifying thread synchronization and lock acquisition during task execution.
+188
View File
@@ -0,0 +1,188 @@
---
sessionId: session-260923-111902-5uwc
---
# Requirements
### Overview & Goals
Replace the hand-rolled `com.sun.net.httpserver` stack in `me.bobert.netneutrality.http.server` with Javalin, so the bot's embedded API needs far fewer custom classes and gains correct status codes, JSON handling and error responses. Additionally simplify the outbound `WebModule` HTTP client.
### Scope
**In scope**
- `WebApi` becomes a thin Javalin-based `Lifecycle` component (same priority 70, same `NETNEUTRALITY_API_PORT` setting).
- Delete `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler`; their logic moves into small route-registration units.
- Standardise the API layer on **Gson** — remove `org.json` (`JSONObject`/`JSONArray`) usage from the HTTP server package.
- Correct HTTP semantics: 404 for unknown guilds/paths, 405 for wrong methods, 500 on exceptions, UTF-8 safe bodies.
- Simplify `WebModule` (outbound OkHttp wrapper): extract the static cache, remove duplicated/unused members.
**Out of scope**
- Changing the public URL shape of existing endpoints (`/`, `/actions/*`, `/guilds/*`) — paths and payload keys stay backwards compatible.
- Authentication/authorisation for the API.
- Migrating `org.json` usage elsewhere in the project (e.g. task classes using `WebModule.getAsObject()`).
### Functional Requirements
- `GET /` → HTML liveness page (unchanged text).
- `GET /actions/status` → `{"health":...,"ping":...}`.
- `POST /actions/shutdown` → runs the shutdown hook and exits (accept `GET` too for compatibility if desired).
- `GET /actions/settings/current/{guildId}` → `{"status":"ok","settings":{...}}`, or 404 + `{"status":"error"}` for an unknown guild.
- `POST /actions/settings/reload/{guildId}` → reloads the guild setting cache, `{"status":"true"}`; 404 for an unknown guild.
- `GET /guilds/owner` → array of `{guild_id, owner_id}`.
- `GET /guilds/channels/{guildId}` and `GET /guilds/roles/{guildId}` → arrays of id/name objects; 404 for an unknown guild.
- Requests arriving before JDA is ready must return 503 rather than throwing an NPE.
### Non-Functional Requirements
- Startup/shutdown stays deterministic through `ApplicationContext` — `stop()` must release the port.
- Shaded jar must still run (`maven-shade-plugin`); Javalin/Jetty service files need merging.
- No behavioural regression for existing consumers of the endpoints.
# Technical Design
### Current Implementation
- `WebApi.java` (92 lines) implements `Lifecycle` (priority 70), creates an `HttpServer`, registers three contexts and a fixed pool built from `BaseThreadFactory("web-api")`. Constructed in `App.java:154`.
- `AbstractHandler` offers `sendStringResponse`/`sendJsonResponse`; both hardcode status 200 and use `str.length()` instead of byte length (breaks non-ASCII).
- `ActionHandler` / `GuildHandler` route via `if (path.startsWith(...))` chains and parse ids with `getRequestURI().toString().split("/")`; they mix Gson and `org.json`, never check the HTTP method, and call `jda.getGuildById()` twice in places.
- `WebModule` is an unrelated *outbound* OkHttp wrapper with a builder, a static LRU cache and a `CachedResponse` record; it builds a brand-new `OkHttpClient` per request and has three redundant timeout setters plus unused `elapsedTime`/`errorMessage` accessors.
### Key Decisions
1. **Javalin** (6.x, JDK 17+ / Java 25 compatible) as the embedded server — removes routing, method matching, error mapping and content-type handling code.
2. **Gson only** in the HTTP layer: register a `GsonJsonMapper` on the Javalin config so `ctx.json(obj)` works with plain records/maps; `org.json` disappears from `http/server`.
3. **Route groups instead of handler classes**: `ActionRoutes` and `GuildRoutes` expose a single `static void register(Javalin app, deps...)`, so `AbstractHandler`, `RootHandler`, `ActionHandler`, `GuildHandler` are all deleted (4 classes → 2 tiny registrars).
4. **Guild lookup guard**: one private helper resolves `{guildId}` → `Guild` or throws `NotFoundResponse`, replacing repeated null checks.
5. **WebModule**: keep the builder API used by callers, but move the cache into a package-private `ResponseCache`, share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()`.
### Proposed Changes
**`http/server/WebApi.java`**
```java
public class WebApi implements Lifecycle {
private Javalin app;
@Override public int priority() { return 70; }
@Override public void start() {
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
app = Javalin.create(cfg -> cfg.jsonMapper(new GsonJsonMapper()));
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
GuildRoutes.register(app, jdaSupplier);
app.exception(Exception.class, (e, ctx) -> { log.error(...); ctx.status(500).json(Map.of("status","error")); });
app.start(port);
}
@Override public void stop() { if (app != null) { app.stop(); app = null; } }
}
```
**`http/server/GuildRoutes.java`** — `/guilds/owner`, `/guilds/channels/{guildId}`, `/guilds/roles/{guildId}` mapped to `List<Map<String,String>>` and returned via `ctx.json(...)`.
**`http/server/ActionRoutes.java`** — `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
**`http/server/GsonJsonMapper.java`** — ~15 lines implementing Javalin's `JsonMapper` over a shared `Gson`.
**`http/WebModule.java`** — shared client, cache extracted, redundant setters/accessors dropped; public builder methods used by callers remain.
### Data Models / Contracts
Small records replace ad-hoc JSON building, serialized by Gson with the existing key names:
```java
record GuildOwner(String guild_id, String owner_id) {}
record NamedEntity(String channel_id, String channel_name) {}
record StatusResponse(String health, String ping) {}
```
### File Structure
```
src/main/java/me/bobert/netneutrality/http/
WebModule.java (modified)
server/WebApi.java (rewritten, Javalin)
server/GsonJsonMapper.java (new)
server/ActionRoutes.java (new)
server/GuildRoutes.java (new)
server/AbstractHandler.java (deleted)
server/RootHandler.java (deleted)
server/ActionHandler.java (deleted)
server/GuildHandler.java (deleted)
pom.xml (+ io.javalin:javalin, shade transformer)
```
### Architecture Diagram
```mermaid
graph LR
App-->ApplicationContext
ApplicationContext-->WebApi
WebApi-->Javalin
WebApi-->GsonJsonMapper
Javalin-->ActionRoutes
Javalin-->GuildRoutes
ActionRoutes-->GuildServiceProvider
GuildRoutes-->JDA
```
### Risks
- **Shaded jar**: Jetty/Javalin rely on `META-INF/services`; the shade plugin needs `ServicesResourceTransformer`, otherwise startup fails only in the packaged build.
- **Jar size / SLF4J binding**: Jetty logs through SLF4J; project already has log4j + slf4j — check for duplicate bindings.
- **JDA not ready**: `jdaSupplier.get()` may be null; guard with a 503 mapping.
- `System.exit(0)` in the shutdown route must run after the response is flushed.
# Testing
### Validation Approach
Build with `./mvnw -q verify` (checkstyle included), then start the app/`WebApi` on an ephemeral port and exercise every endpoint with `curl`, comparing payload shape against the pre-refactor behaviour.
### Key Scenarios
- `GET /` returns 200 and the HTML liveness text.
- `GET /actions/status` returns 200 `application/json` with `health` and `ping`.
- `GET /guilds/owner` returns a JSON array with `guild_id`/`owner_id` keys.
- `GET /guilds/channels/{id}` and `/guilds/roles/{id}` return arrays with `channel_id`/`channel_name`.
- `GET /actions/settings/current/{id}` returns `{"status":"ok","settings":{...}}`.
### Edge Cases
- Unknown guild id → 404 with `{"status":"error"}` (previously 200).
- Unknown path → 404 (previously echoed the path with 200).
- Wrong method on a route → 405.
- Non-ASCII content in settings values round-trips correctly (old `str.length()` bug).
- `Lifecycle.stop()` frees the port — a second `start()` on the same port succeeds.
### Test Changes
- Add a lightweight JUnit test starting `WebApi` on port 0 with a mocked `Supplier<JDA>` and `GuildServiceProvider`, hitting the routes with Javalin's test client or `java.net.http.HttpClient`.
- Add a `WebModule` test covering cache hit/expiry after the cache extraction.
# Delivery Steps
### ✓ Step 1: Add Javalin dependency and Gson JSON mapper
The project builds with Javalin available and a Gson-backed JSON mapper ready for use.
- Add `io.javalin:javalin` (6.x) to `pom.xml` dependencies.
- Add `ServicesResourceTransformer` to the `maven-shade-plugin` configuration so Jetty/Javalin service files survive shading.
- Create `http/server/GsonJsonMapper.java` implementing Javalin's `JsonMapper` over a shared `Gson` instance.
- Verify `./mvnw -q package` succeeds and the shaded jar still starts.
### ✓ Step 2: Rewrite WebApi as a Javalin Lifecycle component
`WebApi` starts and stops a Javalin server while keeping its Lifecycle contract and the root endpoint.
- Replace the `HttpServer`/`ExecutorService` fields with a `Javalin app` field.
- Build the instance in `start()` with the `GsonJsonMapper`, reading `NETNEUTRALITY_API_PORT` from `SettingsProvider` as today.
- Register `GET /` returning the liveness HTML and delete `RootHandler.java`.
- Add global `exception` and `error` handlers returning 500/404 JSON, plus a 503 guard when `jdaSupplier.get()` is null.
- Implement `stop()` via `app.stop()`; keep `priority() == 70` and the `App.java` construction site unchanged.
### ✓ Step 3: Migrate guild endpoints to GuildRoutes
All `/guilds/*` endpoints are served by Javalin route lambdas with proper status codes and Gson serialization.
- Create `http/server/GuildRoutes.register(Javalin, Supplier<JDA>)`.
- Map `GET /guilds/owner`, `GET /guilds/channels/{guildId}`, `GET /guilds/roles/{guildId}` using path params instead of `split("/")`.
- Return `List<record>` payloads via `ctx.json(...)`, preserving the existing `guild_id`/`owner_id`/`channel_id`/`channel_name` keys.
- Resolve guilds through a shared helper that throws `NotFoundResponse` for unknown ids (single `getGuildById` call).
- Delete `GuildHandler.java`.
### ✓ Step 4: Migrate action endpoints to ActionRoutes
All `/actions/*` endpoints run on Javalin, with `org.json` fully removed from the HTTP server package.
- Create `http/server/ActionRoutes.register(Javalin, Supplier<JDA>, GuildServiceProvider, Runnable)`.
- Map `/actions/status`, `/actions/shutdown`, `/actions/settings/current/{guildId}`, `/actions/settings/reload/{guildId}`.
- Replace `JSONObject`/`JSONArray` with Gson-serialized maps/records keeping the same response keys.
- Ensure the shutdown route flushes its response before running the hook and exiting.
- Delete `ActionHandler.java` and `AbstractHandler.java`, and add endpoint tests starting `WebApi` on port 0 with mocked JDA/guild services.
### ✓ Step 5: Simplify the outbound WebModule client
`WebModule` keeps its builder API but sheds duplicated state and its inlined cache.
- Extract the static LRU cache and `CachedResponse` into a package-private `ResponseCache` class in `http/`.
- Share a single static `OkHttpClient` and derive per-request timeouts with `newBuilder()` instead of constructing a client per build.
- Collapse the redundant `timeout`/`connectTimeout`/`readTimeout`/`writeTimeout` setters and remove unused accessors (`getErrorMessage`, `elapsedTime` exposure) after checking call sites.
- Add a unit test covering cache hit, expiry and POST bypass; verify existing `WebModule` callers still compile.
+249
View File
@@ -0,0 +1,249 @@
---
sessionId: session-260923-114918-ce7w
---
# Requirements
### Overview & Goals
Replace the in-process **Lavaplayer** audio stack with a remote **Lavalink v4** node driven by the official `dev.arbjerg:lavalink-client`. Audio decoding, source resolution and voice sending move out of the bot process to the Lavalink node, which can then be extended with server-side plugins (YouTube source, LavaSrc, etc.) without touching the bot.
All dependency/plugin examples from the Lavalink docs are Gradle-based and will be **translated to Maven** (`pom.xml` `<repositories>` / `<dependencies>`); the Lavalink node itself is configured via a YAML file mounted into the container, not via any build script.
### Scope
**In Scope**
- Swap `dev.arbjerg:lavaplayer` + `dev.lavalink.youtube:v2` for `dev.arbjerg:lavalink-client` in `pom.xml`.
- Rewrite the `me.bobert.netneutrality.music` package against Lavalink: `MusicServiceImpl`, `MusicManager`, `TrackScheduler`, `QueueManager`, `TrackInfo`, `TrackLoadingHandler`, `StuckTrackRecoveryHandler`; delete `AudioPlayerSendHandler`.
- Route voice connections through Lavalink's `JDAVoiceUpdateListener` / `DirectAudioController` instead of `Guild#getAudioManager()`.
- Add a `lavalink` service plus `lavalink/application.yml` to `docker-compose.yml`, wired to the existing `ejs-api` (yt-cipher) container.
- Update `MessageFunction`, `LoadPlaylistTask`, `LoadTrackFromPlaylistTask` and the music command classes to the new track type.
- Update the affected tests and the docs under `docs/`.
**Out of Scope**
- Porting `music/source/UdioAudioSourceManager` to a Lavalink server plugin — captured as a documented follow-up. Udio playback is temporarily unavailable; the existing code stays in the repo as the porting reference.
- Any change to command UX, queue semantics, DJ rules or vote-skip behaviour.
- Audio filters/equalizer, node load balancing across multiple nodes, and session resuming (Lavalink supports these; not enabled here).
### User Stories
- As a bot operator, I want audio to be served by a separate Lavalink node so that I can add source plugins and restart the bot without interrupting the audio pipeline's configuration.
- As a user, I want `/play`, `/skip`, `/queue`, `/volume`, `/stop`, `/resume`, `/shuffle` and vote-skip to behave exactly as before the migration.
- As a maintainer, I want YouTube cipher/poToken configuration to live in the node's `application.yml` rather than in Java code.
### Functional Requirements
- All 13 commands in `command/music/` keep their current signatures, replies and `CommandExecutionResult` semantics.
- `SourceFinder` keeps classifying URLs and prefixing searches with `ytsearch:`; identifiers are passed to Lavalink's `loadItem` unchanged.
- Queue ordering, duplicate rejection, auto-start on first enqueue, auto-disconnect on empty queue and the two-strike stuck-track recovery are preserved.
- If the Lavalink node is unreachable at startup, the bot still starts; music commands reply with a clear error instead of crashing.
- Udio URLs reply with a "temporarily unsupported" message rather than failing silently.
### Non-Functional Requirements
- The bot no longer ships native audio encoders: the `jdave-api` / `jdave-native-*` dependencies and the `AudioModuleConfig` DAVE wiring in `App.initJDA()` are removed, since DAVE is handled by the node (Lavalink ≥ 4.2.0).
- New settings are read through the existing `SettingsProvider` and documented in `bot.properties.example`.
- Checkstyle (`checkstyle.xml`, bound to the `test` phase) must keep passing.
# Technical Design
### Current Implementation
- `MusicServiceImpl` (priority-40 `Lifecycle`, registered in `App.buildContext()` line 140) owns a `DefaultAudioPlayerManager` and registers YouTube (with `setRemoteCipher(...)`), SoundCloud, Udio and HTTP source managers in `start()`; `stop()` clears queues and calls `player.destroy()`.
- `MusicManager` creates an `AudioPlayer` per guild, sets `AudioPlayerSendHandler` on `guild.getAudioManager()`, opens/closes the voice connection itself, and calls `playerManager.loadItemOrdered(this, identifier, new TrackLoadingHandler(...))`.
- `TrackScheduler extends AudioEventAdapter` and receives `onTrackStart/End/Exception/Stuck` callbacks directly from the player.
- `QueueManager` holds a `Deque<TrackInfo>` and drives playback with `player.startTrack(track.makeClone(), false)` / `player.stopTrack()`.
- `StuckTrackRecoveryHandler` relies on `AudioTrack#makeClone`, `getPosition()` and `setPosition()`.
- `com.sedmelluq.*` types appear in 14 main sources and 5 tests (`QueueManagerTest`, `TrackInfoTest`, `TrackSchedulerTest`, `VoteSkipServiceTest`, `UdioAudioSourceManagerTest`).
- `pom.xml` already declares the `https://maven.lavalink.dev/releases` repository (lines 136–140).
- `docker-compose.yml` runs `db`, `bot` and `ejs_signature_api` (yt-cipher) on the `netneutrality` network; there is no Lavalink node.
### Key Decisions
1. **Official client** — `dev.arbjerg:lavalink-client` (v4-only). `VoiceDispatchInterceptor` is verified present in the bundled JDA 6.4.1 jar, so `JDAVoiceUpdateListener` attaches cleanly.
2. **Central event dispatcher** — `MusicServiceImpl` holds the single subscription to the client's event flux and routes each event to the owning guild's `TrackScheduler`. One `Disposable` to dispose in `stop()`.
3. **Synchronous facade** — `MusicManager` subscribes to the returned `Mono`s internally; command classes keep their current synchronous signatures. Failures are reported via `LogHandler` and the attached `MusicChannelContext`.
4. **Lavalink `Track` everywhere** — `dev.arbjerg.lavalink.protocol.v4.Track` replaces `AudioTrack` in `TrackInfo`, `QueueManager`, `MessageFunction` and the playlist tasks. `Track` is an immutable data class, so `makeClone()` disappears and `setPosition` becomes `player.setPosition(...)` on the Lavalink player.
5. **Node in compose** — a `lavalink` service with a mounted `application.yml`; YouTube cipher/poToken config moves out of `MusicServiceImpl.start()` into that YAML.
6. **Udio deferred** — `music/source/**` is excluded from compilation-relevant wiring and the `Udio` branch of `SourceFinder.getIdentifier` returns `null` with a friendly reply; the classes remain for the future plugin port.
### Proposed Changes
**Maven (`pom.xml`)** — translated from the docs' Gradle snippets:
```xml
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavalink-client</artifactId>
<version>3.4.0</version>
</dependency>
```
resolved from the already-present `arbjergDev-releases` repository. Removed: `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2`, `club.minnced:jdave-*`.
**`MusicServiceImpl`**
- Field `LavalinkClient client = new LavalinkClient(Helpers.getUserIdFromToken(token))`.
- `start()` registers one node (`client.addNode(...)` with URI, password and name from settings) and opens the dispatcher subscription:
`client.on(TrackStartEvent.class)`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`, `WebSocketClosedEvent` → look up `managers.get(event.getGuildId())` → forward to its `TrackScheduler`.
- `stop()` disposes the subscriptions, clears queues, destroys players and closes the client.
- Exposes `LavalinkClient getClient()` in place of `getPlayerManager()`.
**`App`**
- `initJDA()` adds `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` and drops `.setAudioModuleConfig(new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))`.
- Ordering: the `LavalinkClient` must exist before JDA is built, which the existing priorities already guarantee (music = 40, JDA = 60).
- Dev log levels switch from `com.sedmelluq.discord` / `dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
**`MusicManager`**
- Holds `LavalinkClient client` and resolves `Link link = client.getOrCreateLink(guildId)`.
- `connect(AudioChannel)` → `jda.getDirectAudioController().connect(channel)` + `setSelfDeafened`; `disconnect()` → `getDirectAudioController().disconnect(guild)` and `link.destroyPlayer()`.
- `isConnected()` / `isNotConnectedTo(...)` read `guild.getSelfMember().getVoiceState().getChannel()` — **`Guild#getAudioManager()` must not be used** with Lavalink.
- `play(...)` → `link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))`.
- `getCurrentlyPlaying()` returns the cached `Track` from the last `TrackStartEvent` (the REST player state is not synchronously readable).
- `setVolume` / `stopTrack` → `link.getPlayer().flatMap(p -> p.setVolume(v).setEncodedTrack(null))` subscribed internally.
**`TrackScheduler`**
- No longer extends `AudioEventAdapter`; exposes `onTrackStart(Track)`, `onTrackEnd(Track, AudioTrackEndReason)`, `onTrackException`, `onTrackStuck(Track, long)` called by the dispatcher.
- The existing `endReason` branching maps 1:1 onto Lavalink's `AudioTrackEndReason` (`FINISHED`, `LOAD_FAILED`, `STOPPED`, `REPLACED`, `CLEANUP`).
**`QueueManager`**
- `player.getPlayingTrack() == null` becomes a `volatile Track currentTrack` maintained by scheduler events (avoids a blocking REST call inside the `synchronized (queue)` blocks).
- `startTrack(track, noInterrupt)` becomes `link.createOrUpdatePlayer().setTrack(track).setNoReplace(...)`; `makeClone()` calls are dropped since `Track` is immutable.
**`TrackLoadingHandler`** — implements lavalink-client's `AbstractAudioLoadResultHandler`: `ontrackLoaded` / `onPlaylistLoaded` / `onSearchResultLoaded` / `noMatches` / `loadFailed(LoadFailed)`. `onSearchResultLoaded` takes the first result, matching today's `ytsearch:` behaviour.
**`StuckTrackRecoveryHandler`** — re-queues the same immutable `Track` and restores position via `player.setPosition(lastPosition)`; the second consecutive strike still skips.
### Data Models / Contracts
```java
// MusicManager (synchronous facade, unchanged call sites)
public synchronized void play(String query, CommandContext ctx, boolean silent);
public synchronized void stopTrack();
public synchronized void setVolume(int volume);
public synchronized Track getCurrentlyPlaying();
// TrackScheduler (invoked by the dispatcher)
void onTrackStart(Track track);
void onTrackEnd(Track track, AudioTrackEndReason endReason);
void onTrackStuck(Track track, long thresholdMs);
// TrackInfo
public record TrackInfo(Track track, Member requester) { }
```
New settings (read via `SettingsProvider`, documented in `bot.properties.example`):
`NETNEUTRALITY_LAVALINK_URI` (default `ws://lavalink:2333`), `NETNEUTRALITY_LAVALINK_PASSWORD`, `NETNEUTRALITY_LAVALINK_NAME`.
### File Structure
| Path | Change |
|---|---|
| `pom.xml` | swap lavaplayer/youtube-v2/jdave → `lavalink-client` |
| `docker-compose.yml` | add `lavalink` service |
| `lavalink/application.yml` | **new** — node + youtube-source plugin + yt-cipher config |
| `bot.properties.example` | new Lavalink settings |
| `music/MusicServiceImpl.java` | client lifecycle + event dispatcher |
| `music/MusicManager.java` | `Link`/`DirectAudioController` based |
| `music/TrackScheduler.java` | plain event sink |
| `music/QueueManager.java`, `TrackInfo.java`, `TrackLoadingHandler.java`, `StuckTrackRecoveryHandler.java` | `Track` type + REST player calls |
| `music/AudioPlayerSendHandler.java` | **deleted** |
| `music/SourceFinder.java` | Udio → unsupported |
| `App.java` | voice interceptor, drop DAVE wiring, log levels |
| `shared/util/MessageFunction.java`, `tasks/music/LoadPlaylistTask.java`, `tasks/music/LoadTrackFromPlaylistTask.java` | `Track` type |
| `src/test/.../music/*` | updated fixtures |
| `docs/music.md`, `docs/architecture.md`, `docs/README.md` | rewritten sections |
### Architecture Diagram
```mermaid
graph LR
JDA[JDA gateway] -->|voice state| VIL[JDAVoiceUpdateListener]
VIL --> C[LavalinkClient]
C -->|REST and WS| N[Lavalink node]
N --> V[Discord voice]
CMD[music commands] --> MM[MusicManager]
MM -->|Link ops| C
MM --> QM[QueueManager]
C -->|event flux| DISP[MusicServiceImpl dispatcher]
DISP --> TS[TrackScheduler]
TS --> QM
```
### Risks
- **Voice API confusion**: any remaining `guild.getAudioManager()` call silently breaks playback; all call sites (`MusicManager`, `MusicUtils`, `BotState`) must be swept.
- **Blocking on reactor**: subscribing inside `synchronized` blocks in `QueueManager` risks deadlock — hence the cached `currentTrack` instead of a REST read.
- **Node availability**: `MusicServiceImpl.start()` must not throw when the node is down, or `App.startup()` exits with code 1.
- **Feature regressions**: Udio support pauses; direct-MP3 and SoundCloud must be re-verified against the node's enabled sources.
- **DAVE**: removing JDave requires Lavalink ≥ 4.2.0 — the compose image must be pinned accordingly.
# Testing
### Validation Approach
- `./mvnw -q verify` must compile and pass Checkstyle plus the updated unit tests.
- Unit tests keep using mocks: `QueueManagerTest` and `TrackSchedulerTest` mock the Lavalink `Link`/`LavalinkPlayer` and assert on the interactions instead of on `AudioPlayer`.
- A grep sweep confirms no `com.sedmelluq` / `dev.lavalink.youtube` import survives outside `music/source/**`.
- Manual smoke run with `docker compose up` to confirm the bot reaches the node and plays a YouTube URL.
### Key Scenarios
- Enqueue on an empty queue starts playback immediately; enqueue while playing appends.
- `TrackEndEvent(FINISHED)` advances to the next track; an empty queue triggers `onQueueEmpty()` → disconnect.
- `skip(n)`, `shuffle`, `clearQueue`, `removeTracksFromUser` and `removeFromQueue(index)` return the same `CommandExecutionResult` messages as before.
- `ytsearch:` queries resolve through `onSearchResultLoaded` and queue the first hit.
- Volume from `GSettings.MUSIC_VOLUME` is applied on connect, with the 50 fallback on a malformed value.
### Edge Cases
- Lavalink node unreachable at startup → bot boots, music commands reply with an error.
- Node disconnects mid-playback → `WebSocketClosedEvent` is logged and the guild state is cleaned up.
- Stuck track: first event re-queues and seeks, second consecutive event skips.
- Udio URL → explicit "temporarily unsupported" reply.
- Duplicate track enqueue is still rejected by `TrackInfo` equality.
### Test Changes
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest`, `VoteSkipServiceTest` to the Lavalink `Track` type and mocked players.
- Keep `UdioAudioSourceManagerTest` as-is while the source classes remain in the repo for the future plugin port.
# Delivery Steps
### ✓ Step 1: Swap Maven dependencies and stand up a Lavalink node
The project builds against `lavalink-client` and `docker compose up` starts a reachable Lavalink v4 node.
- Replace `dev.arbjerg:lavaplayer`, `dev.lavalink.youtube:v2` and the four `club.minnced:jdave-*` entries in `pom.xml` with `dev.arbjerg:lavalink-client` (Gradle docs snippet translated to Maven, using the existing `arbjergDev-releases` repository).
- Add a `lavalink` service to `docker-compose.yml` on the `netneutrality` network, pinned to an image with DAVE support (≥ 4.2.0), with the bot depending on it.
- Create `lavalink/application.yml` mounted into the node: server port/password, the youtube-source plugin, and the remote cipher pointed at the existing `ejs-api` container plus poToken/visitorData.
- Add `NETNEUTRALITY_LAVALINK_URI`, `_PASSWORD` and `_NAME` to `bot.properties.example`.
### ✓ Step 2: Wire LavalinkClient into the bootstrap and JDA voice handling
The bot connects to the node on startup and routes Discord voice through Lavalink instead of JDA's audio manager.
- Rework `MusicServiceImpl` to own a `LavalinkClient`, registering the configured node in `start()` without throwing when it is unreachable.
- Dispose the client and its subscriptions in `stop()`, keeping the priority-40 `Lifecycle` contract used by `App.buildContext()`.
- Add `.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(client))` in `App.initJDA()` and remove the `AudioModuleConfig`/`JDaveSessionFactory` wiring.
- Update the dev-mode log levels in `App.main` from `com.sedmelluq.discord`/`dev.lavalink.youtube` to `dev.arbjerg.lavalink`.
- Replace `getPlayerManager()` with `getClient()` and fix `BotState`'s usage.
### ✓ Step 3: Rebuild MusicManager on Links and the DirectAudioController
Per-guild connect, disconnect and player control go through Lavalink while command signatures stay unchanged.
- Replace the `AudioPlayer` field in `MusicManager` with a `Link` obtained from `client.getOrCreateLink(guildId)`.
- Implement `connect`/`disconnect` via `jda.getDirectAudioController()` and `link.destroyPlayer()`, deleting the `guild.getAudioManager()` calls and the `AudioPlayerSendHandler` class.
- Base `isConnected()` and `isNotConnectedTo(...)` on `getSelfMember().getVoiceState().getChannel()`, and update `MusicUtils.connect`/`checkIfConnected` accordingly.
- Keep the synchronous facade: `stopTrack`, `setVolume` and `play` subscribe to the returned `Mono`s internally and report failures through `LogHandler`.
- Apply the `GSettings.MUSIC_VOLUME` base volume on connect with the existing default-50 fallback.
### ✓ Step 4: Migrate queue, track loading and message rendering to the Lavalink Track type
Tracks are represented by `dev.arbjerg.lavalink.protocol.v4.Track` end to end and loading uses Lavalink's REST resolver.
- Change `TrackInfo` to wrap `Track` + `Member`, preserving equality-based duplicate rejection.
- Update `QueueManager` to drive playback with `createOrUpdatePlayer().setTrack(...)`/`setNoReplace(...)`, dropping `makeClone()` and replacing `player.getPlayingTrack()` checks with a scheduler-maintained `volatile Track currentTrack`.
- Reimplement `TrackLoadingHandler` on `AbstractAudioLoadResultHandler`, mapping `onSearchResultLoaded` to the first hit to preserve the `ytsearch:` behaviour.
- Update `MessageFunction`, `LoadPlaylistTask` and `LoadTrackFromPlaylistTask` to the new track type.
- Make `SourceFinder` return no identifier for Udio URLs and have `MusicManager.play` reply that Udio is temporarily unsupported.
### ✓ Step 5: Route Lavalink events to TrackScheduler and restore stuck-track recovery
Track lifecycle events from the node drive queue advancement, error reporting and stuck recovery exactly as before.
- Add a central dispatcher in `MusicServiceImpl` subscribing once to `TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent` and `WebSocketClosedEvent`, routing by guild id to the cached `MusicManager`.
- Convert `TrackScheduler` from an `AudioEventAdapter` subclass into a plain sink with `onTrackStart`/`onTrackEnd`/`onTrackException`/`onTrackStuck`, keeping the existing `FINISHED`/`LOAD_FAILED`/`STOPPED` branching and `MusicChannelContext` attachment.
- Rework `StuckTrackRecoveryHandler` to re-queue the immutable `Track` and seek with `player.setPosition(...)`, retaining the retry-then-skip rule.
- Update `QueueManagerTest`, `TrackSchedulerTest`, `TrackInfoTest` and `VoteSkipServiceTest` to mock `Link`/`LavalinkPlayer`, and verify `./mvnw verify` passes Checkstyle and the suite.
### ✓ Step 6: Update documentation and record the Udio plugin follow-up
The docs describe the Lavalink-based architecture and the deferred Udio work is explicit.
- Rewrite the Lavaplayer sections of `docs/music.md` and `docs/architecture.md` to cover the node, the client, the dispatcher and the new settings.
- Refresh the music mentions in `docs/README.md`, `docs/threading.md` and `docs/utilities-and-bootstrap.md`.
- Document how to run the node locally and how to add server-side plugins via `lavalink/application.yml`.
- Add a follow-up note stating that `music/source/UdioAudioSourceManager` and `UdioAudioTrack` remain only as the reference for a future Lavalink server plugin and that Udio playback is disabled until then.
+4
View File
@@ -20,6 +20,10 @@ NETNEUTRALITY_VISITOR_DATA=
NETNEUTRALITY_CIPHER_URL=http://localhost:8001
NETNEUTRALITY_CIPHER_PASSWORD=
NETNEUTRALITY_LAVALINK_URI=ws://lavalink:2333
NETNEUTRALITY_LAVALINK_PASSWORD=youshallnotpass
NETNEUTRALITY_LAVALINK_NAME=main
NETNEUTRALITY_SENTRY_DSN=
GITLAB_HOST=
GITLAB_TOKEN=
+47 -5
View File
@@ -23,14 +23,39 @@ services:
- netneutrality
depends_on:
- netneutrality-db
- lavalink
volumes:
- ./logs:/app/logs
environment:
MYSQL_HOST: netneutrality-db
MYSQL_PORT: 3306
MYSQL_USER: java
MYSQL_PASSWORD: password
MYSQL_DATABASE: netneutrality
- MYSQL_HOST=${MYSQL_HOST:-netneutrality-db}
- MYSQL_PORT=${MYSQL_PORT:-3306}
- MYSQL_USER=${MYSQL_USER:-java}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-password}
- MYSQL_DATABASE=${MYSQL_DATABASE:-netneutrality}
- API_PORT=${NETNEUTRALITY_API_PORT:-9000}
# Lavalink >= 4.2.0 is required: the node handles DAVE end-to-end encryption,
# so the bot no longer ships native encoders.
lavalink:
image: ghcr.io/lavalink-devs/lavalink:4.2
container_name: lavalink
restart: unless-stopped
networks:
- netneutrality
depends_on:
- ejs-api
- webpo
volumes:
- ./lavalink/application.yml:/opt/Lavalink/application.yml:ro
environment:
- SERVER_PASSWORD=${NETNEUTRALITY_LAVALINK_PASSWORD:-youshallnotpass}
- NETNEUTRALITY_CIPHER_URL=${NETNEUTRALITY_CIPHER_URL:-http://ejs-api:8001}
- NETNEUTRALITY_CIPHER_PASSWORD=${NETNEUTRALITY_CIPHER_PASSWORD:-}
- NETNEUTRALITY_OAUTH_TOKEN=${NETNEUTRALITY_OAUTH_TOKEN:-}
- NETNEUTRALITY_POT_URL=${NETNEUTRALITY_POT_URL:-http://webpo:8080}
- NETNEUTRALITY_PO_TOKEN=${NETNEUTRALITY_PO_TOKEN:-}
- NETNEUTRALITY_VISITOR_DATA=${NETNEUTRALITY_VISITOR_DATA:-}
ejs-api:
image: ghcr.io/kikkia/yt-cipher:master@sha256:4c5ec381ff57336cfc24822d2b526a5ad7cd0171f61065a1bbc87bb736842449
container_name: ejs_signature_api
@@ -39,8 +64,25 @@ services:
- netneutrality
# environment: # UNCOMMENT THIS LINE AND API_TOKEN TO ADD A PASSWORD
# - API_TOKEN=test
webpo:
image: ghcr.io/ashton045/webpo-generator
container_name: webpo-generator
environment:
- HOST=${HOST:-::}
- PORT=${PORT:-8080}
- WORKERS=${WORKERS:-1}
- QUEUE_SIZE=${QUEUE_SIZE:-32}
- MAX_PENDING_REQUESTS=${MAX_PENDING_REQUESTS:-256}
- REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30000}
- CACHE_SIZE=${CACHE_SIZE:-100}
- CACHE_TTL=${CACHE_TTL:-300000}
- VISITOR_TTL=${VISITOR_TTL:-600000}
- API_TOKEN=
restart: unless-stopped
volumes:
netneutrality-db: null
netneutrality-lavalink-plugins: null
networks:
netneutrality: null
+1 -1
View File
@@ -16,7 +16,7 @@ subsystem, how they fit together, and the end-to-end flows that tie them.
| [commands.md](commands.md) | Commands — message & slash commands, dispatch, options |
| [tasks.md](tasks.md) | Tasks — button/emoji/modal/step/built-in/persistable/scheduled tasks |
| [persistence.md](persistence.md) | Database — Hibernate/JPA, Flyway, entities, repositories, services |
| [music.md](music.md) | Music — Lavaplayer pipeline, queue, custom Udio source |
| [music.md](music.md) | Music — Lavalink node & client, queue, event dispatch |
| [web-api.md](web-api.md) | HTTP Web API — internal control & introspection server |
| [threading.md](threading.md) | Threading & scheduling infrastructure |
| [utilities-and-bootstrap.md](utilities-and-bootstrap.md) | Utilities, configuration, logging, exceptions |
+11 -10
View File
@@ -37,7 +37,7 @@ execution paths:
directly and no context is routed onward. See [events.md](events.md).
Around this core are the supporting subsystems: [persistence](persistence.md) (Hibernate + Flyway),
[music](music.md) (Lavaplayer), the [web API](web-api.md), and the [threading/scheduling](threading.md)
[music](music.md) (a remote Lavalink node), the [web API](web-api.md), and the [threading/scheduling](threading.md)
infrastructure.
## Technology stack
@@ -45,9 +45,8 @@ infrastructure.
| Concern | Library | Version |
|---|---|---|
| Discord API | `net.dv8tion:JDA` | 6.4.1 |
| Voice / DAVE (E2EE) | `club.minnced:jdave-*` | 0.1.8 |
| Audio engine | `dev.arbjerg:lavaplayer` | 2.2.6 |
| YouTube source | `dev.lavalink.youtube:v2` | 1.18.2 |
| Audio engine | Lavalink v4 node (`ghcr.io/lavalink-devs/lavalink`) | 4.2 |
| Audio client | `dev.arbjerg:lavalink-client` | 3.4.0 |
| ORM | `org.hibernate.orm:hibernate-core` | 7.1.9.Final |
| Connection pool | `hibernate-hikaricp` (HikariCP) | 7.1.9.Final |
| JDBC driver | `com.mysql:mysql-connector-j` | 9.7.0 |
@@ -77,7 +76,7 @@ component in ascending priority order.
also registered into the context as `SettingsProvider` for constructor injection).
2. **Sentry** — if `NETNEUTRALITY_SENTRY_DSN` is set, initialize the Sentry SDK
(environment, release = `VERSION`, traces sample rate 1.0).
3. **Log levels** — in `dev`, raise JDA / Lavalink-YouTube / sedmelluq logging to `DEBUG`.
3. **Log levels** — in `dev`, raise JDA and Lavalink client logging to `DEBUG`.
4. `startup()`:
1. Validate `NETNEUTRALITY_BOT_TOKEN` is present (throws if missing).
2. `buildContext()` — register domain services and lifecycle adapters (see priorities below).
@@ -92,7 +91,7 @@ component in ascending priority order.
| 20 | `ThreadManager` (`ExecutorProvider`) | No-op start (executors are lazy). Bound into `ChannelMessagePurger`. |
| 30 | `CommandManager` (`CommandRegistry`) | Reflectively discover/register `Command` implementations with context-aware constructor injection. |
| 30 | `TaskManagerImpl` (`TaskManager`) | Reflectively load built-in & scheduled tasks (DI) and reload persistable tasks from the DB. |
| 40 | `MusicServiceImpl` (`MusicService`) | Initialise the shared Lavaplayer `AudioPlayerManager`. |
| 40 | `MusicServiceImpl` (`MusicService`) | Create the shared `LavalinkClient`, register the node and subscribe to its events. |
| 55 | `ScheduledTaskManager` | No-op start (tasks are scheduled by `TaskManager` during its own start). |
| 60 | JDA adapter | Build `JDA` with gateway intents and await ready. |
| 65 | Post-JDA adapter | Register slash commands, attach `EventsHandler` (injected providers), set presence. |
@@ -106,8 +105,10 @@ context (falling back to a no-arg constructor when none match).
### Gateway intents
`initJDA()` enables: `GUILD_MEMBERS`, `GUILD_MESSAGE_REACTIONS`, `GUILD_VOICE_STATES`,
`MESSAGE_CONTENT`. It also installs the DAVE (end-to-end encrypted voice) session factory via
`AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory())`.
`MESSAGE_CONTENT`. It also installs `new JDAVoiceUpdateListener(music.getClient())` as the voice
dispatch interceptor, so voice server/state updates are forwarded to Lavalink instead of JDA's own
audio stack. DAVE (end-to-end encrypted voice) is handled by the node, which is why the bot no
longer ships native audio encoders.
## Shutdown lifecycle
@@ -120,7 +121,7 @@ block the others. Effectively this means:
2. Post-JDA adapter — no-op.
3. JDA adapter — `jda.shutdown()`.
4. `ScheduledTaskManager.stop()` — cancel all scheduled tasks.
5. `MusicServiceImpl.stop()` — disconnect players / leave voice channels.
5. `MusicServiceImpl.stop()` — dispose the event subscriptions, destroy the players and close the Lavalink client.
6. `CommandManager` / `TaskManagerImpl` — no-op stop today.
7. `ThreadManager.stop()` — shut down the shared executors.
8. `SessionManager.stop()` — release the Hibernate registry / connection pool.
@@ -155,7 +156,7 @@ block the others. Effectively this means:
| `…db.entities` / `…db.repository` / `…db.service` | Persistence model, repositories, services |
| `…hooks` | `EventsHandler` |
| `…http` | Internal web API and HTTP client module |
| `…music` | Music domain (service, manager, queue, sources, tracks) |
| `…music` | Music domain (service, manager, queue, scheduler, track loading) |
| `…utils` | `Settings` / `SettingsProvider`, `ChannelMessagePurger`, `MusicUtils` |
| `…shared.util` | Cross-cutting helpers |
| `…exceptions` | Custom exception hierarchy |
+115 -38
View File
@@ -1,35 +1,51 @@
# Music
**Package:** `me.bobert.netneutrality.feature.music`
**Engine:** [Lavaplayer](https://github.com/lavalink-devs/lavaplayer) 2.2.6 + the Lavalink YouTube
source 1.18.2.
**Package:** `me.bobert.netneutrality.music`
**Engine:** a remote [Lavalink](https://lavalink.dev) v4 node driven by
[`dev.arbjerg:lavalink-client`](https://github.com/lavalink-devs/Lavalink-Client) 3.4.0.
The music subsystem provides per-guild audio playback: searching/loading tracks from multiple
sources, queueing, scheduling, and streaming audio into a Discord voice channel.
sources, queueing, scheduling, and streaming audio into a Discord voice channel. Since the
migration to Lavalink, **decoding, source resolution and voice sending happen on the node**, not in
the bot process. The bot only sends REST/WebSocket commands and reacts to the node's events, which
means new sources (YouTube, LavaSrc, …) are added by configuring server-side plugins rather than by
changing Java code.
## Key classes
| Class | Responsibility |
|---|---|
| `MusicManager` | Central per-guild coordinator; owns the `AudioPlayer` and `TrackScheduler`. |
| `MusicServiceImpl` | Owns the shared `LavalinkClient`, registers the node, and dispatches its event flux. |
| `MusicManager` | Central per-guild coordinator; resolves the guild's `Link` and exposes a synchronous facade. |
| `QueueManager` | Holds the guild's track queue (`LinkedBlockingDeque<TrackInfo>`); enqueue/skip/shuffle. |
| `TrackScheduler` | `AudioEventAdapter`; reacts to Lavaplayer events (`onTrackEnd`, `onTrackStuck`). |
| `TrackLoadingHandler` | `AudioLoadResultHandler`; handles track/playlist/no-match/failure results. |
| `TrackInfo` | Value object pairing an `AudioTrack` with its requester (`Member`). |
| `SourceFinder` | Identifies the source/type of a query (YouTube, Udio, HTTP, …). |
| `AudioPlayerSendHandler` | Bridges Lavaplayer output to JDA's `AudioSendHandler`. |
| `source/UdioAudioSourceManager` | Custom Lavaplayer source manager for udio.com. |
| `source/UdioAudioTrack` | Udio track; delegates playback to `Mp3AudioTrack`. |
| `TrackScheduler` | Event sink for `onTrackStart` / `onTrackEnd` / `onTrackException` / `onTrackStuck`; caches the current track. |
| `TrackLoadingHandler` | `AbstractAudioLoadResultHandler`; handles track/playlist/search/no-match/failure results. |
| `TrackInfo` | Value object pairing a Lavalink `Track` with its requester (`Member`). |
| `SourceFinder` | Identifies the source/type of a query (YouTube, SoundCloud, HTTP, …). |
| `StuckTrackRecoveryHandler` | Retry-then-skip logic for tracks the node reports as stuck. |
`MusicManager` keeps a static `Map<String, MusicManager>` of managers keyed by guild id, so each guild
has its own player, scheduler, and queue.
`MusicServiceImpl` keeps a `Map<String, MusicManager>` of managers keyed by guild id, so each guild
has its own link, scheduler, and queue.
## Startup
`MusicManager.initPlayerManager()` (called from
[`App.startup()`](architecture.md#startup-lifecycle)) creates the Lavaplayer
`AudioPlayerManager` and registers the source managers (YouTube, SoundCloud, the custom
[Udio source](#custom-udio-source), and HTTP).
`MusicServiceImpl` is a priority-40 [`Lifecycle`](architecture.md#startup-lifecycle) component. Its
constructor creates the `LavalinkClient` (the bot user id is derived from the token) and `start()`
registers the configured node. **A node that is unreachable never prevents the bot from booting**:
the client retries in the background and music commands reply with an error in the meantime.
Because the client must exist before JDA is built, `App.initJDA()` (priority 60) installs
`new JDAVoiceUpdateListener(music.getClient())` as the gateway's voice dispatch interceptor. JDA's
`AudioManager` is therefore **not** used for playback; voice connections are opened and closed
through `JDA#getDirectAudioController()`.
### Settings
| Setting | Default | Meaning |
|---|---|---|
| `NETNEUTRALITY_LAVALINK_URI` | `ws://lavalink:2333` | WebSocket URI of the node |
| `NETNEUTRALITY_LAVALINK_PASSWORD` | — | Node password; music is disabled when missing |
| `NETNEUTRALITY_LAVALINK_NAME` | `main` | Node name used in logs |
## Audio pipeline (end-to-end)
@@ -38,50 +54,111 @@ has its own player, scheduler, and queue.
│
▼
MusicManager.play(query, ctx)
│ SourceFinder identifies the source
│ SourceFinder identifies the source and builds the identifier
▼
audioPlayerManager.loadItemOrdered(guild, identifier, new TrackLoadingHandler(...))
link.loadItem(identifier).subscribe(new TrackLoadingHandler(...))
│
├─ trackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
├─ playlistLoaded / searchResults ──▶ install a LoadPlaylistTask (choice buttons)
├─ ontrackLoaded ──▶ connect to voice (MusicUtils) ──▶ QueueManager.queue(track, member)
├─ onPlaylistLoaded / onSearchResultLoaded ──▶ install a LoadPlaylistTask (choice buttons)
├─ noMatches ──▶ reply "nothing found"
└─ loadFailed ──▶ reply / log error
│
▼
QueueManager: if the player is idle, start immediately; else append to the deque
QueueManager: if nothing is playing, send createOrUpdatePlayer().setTrack(...); else append
│
▼
AudioPlayerSendHandler streams PCM/Opus frames to the voice channel
Lavalink node decodes and streams the audio to the Discord voice gateway
│
▼ (track ends)
TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
node event ──▶ MusicServiceImpl dispatcher ──▶ TrackScheduler.onTrackEnd ──▶ QueueManager.startNextTrack()
```
- **Event dispatch** — `MusicServiceImpl` holds a single subscription per event type
(`TrackStartEvent`, `TrackEndEvent`, `TrackExceptionEvent`, `TrackStuckEvent`,
`WebSocketClosedEvent`) and routes each event to the owning guild's `TrackScheduler`. All
subscriptions are disposed in `stop()`.
- **Synchronous facade** — every Lavalink operation returns a reactive `Mono`. `MusicManager`
subscribes internally so the 13 music commands keep their existing synchronous signatures;
failures are reported through `LogHandler` and the attached `MusicChannelContext`.
- **Current track** — the node's player state is only readable via REST, so the last
`TrackStartEvent` is cached on the scheduler and used by `getCurrentlyPlaying()` and by the queue
to decide whether playback must be started.
- **Requester tracking** — each queued item is a `TrackInfo` (track + requesting `Member`), used for
now-playing/queue displays and vote-skip.
- **Playlists / search** — playlist and multi-result loads produce a
now-playing/queue displays and vote-skip. Duplicate rejection compares the encoded track payload.
- **Playlists / search** — playlist and search loads produce a
[`LoadPlaylistTask`](tasks.md#concrete-task-catalog) with per-result buttons
(`LoadTrackFromPlaylistTask`), tying the music and [task](tasks.md) subsystems together. Playlist
size is bounded by the [`MUSIC_MAXPLAYLISTSIZE`](persistence.md#gsettings) guild setting.
- **Stuck tracks** — `TrackScheduler.onTrackStuck` attempts recovery (reload/skip).
- **Stuck tracks** — the first `TrackStuckEvent` re-queues the track and seeks back to the last
known position; a second consecutive event skips to the next track.
- **Output** — playback messages are posted through a
[`MusicChannelContext`](contexts.md#musicchannelcontext) so they land in the guild's configured
music channel.
## Custom Udio source
## Running the node
`UdioAudioSourceManager` scrapes udio.com track pages: it extracts track metadata (title, artist,
duration) and a direct playback URL from the Next.js payload (`self.__next_f.push(...)` calls) in the
page HTML. Because Udio playback URLs expire, `UdioAudioTrack` **re-fetches** the URL during the
`process` phase of the pipeline before streaming, delegating the actual decode/playback to Lavaplayer's
`Mp3AudioTrack`.
`docker-compose.yml` defines a `lavalink` service (image `ghcr.io/lavalink-devs/lavalink:4.2`, the
minimum version that handles DAVE end-to-end encrypted voice) on the `netneutrality` network, with
`lavalink/application.yml` mounted read-only at `/opt/Lavalink/application.yml`. The bot depends on
it and the node depends on the existing `ejs-api` (yt-cipher) container.
```bash
docker compose up -d lavalink
docker compose logs -f lavalink # wait for "Lavalink is ready to accept connections"
```
### Adding a server-side plugin
Plugins are declared in `lavalink/application.yml` and downloaded by the node on boot into the
`netneutrality-lavalink-plugins` volume — no bot rebuild is required:
```yaml
lavalink:
plugins:
- dependency: "dev.lavalink.youtube:youtube-plugin:1.18.2"
snapshot: false
```
A plugin that is not on the default Lavalink repository can be pulled from any Maven repository by
adding `repository:` to the entry. The node currently runs a JitPack build of the SABR-enabled
YouTube fork ([`ashton045/youtube-source`](https://jitpack.io/#ashton045/youtube-source), branch
`feat/sabr-support`) — no local build of the fork is required, JitPack publishes a ready Lavalink
plugin jar:
```yaml
lavalink:
plugins:
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:17619654b1"
repository: "https://jitpack.io"
snapshot: false
```
The node logs `Failed to check for updates for youtube-plugin: Not Found` on boot; this is harmless,
JitPack publishes no `maven-metadata.xml` for commit-pinned versions.
Plugin-specific configuration goes under the top-level `plugins:` key. The YouTube plugin is
configured there with the remote cipher URL, poToken and visitorData, which used to live in
`MusicServiceImpl.start()`.
## Udio support (deferred)
Udio playback is **temporarily unavailable**. The previous implementation was a client-side
Lavaplayer `AudioSourceManager`, and Lavalink can only resolve sources that live on the server.
`SourceFinder` still recognises Udio URLs but returns no identifier, and `MusicManager.play` replies
that Udio is temporarily unsupported.
The original `UdioAudioSourceManager`, `UdioAudioTrack` and their test have been preserved verbatim
under [`docs/reference/udio-lavalink-plugin/`](reference/udio-lavalink-plugin) — they are **not
compiled** — as the reference for a future Lavalink **server plugin** port. That plugin is out of
scope of this migration and is tracked as a follow-up.
## Guild lifecycle & cleanup
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the player has been idle.
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances from the
static map.
- On shutdown, `MusicManager.disconnectPlayers()` stops all players and disconnects from voice
- `MusicPlayerAutoDisconnect` (scheduled task) leaves voice when the channel is empty or the queue
has run out; the connected channel is read from the bot's gateway voice state.
- `MusicManagerCleanup` (scheduled task) evicts unused per-guild `MusicManager` instances.
- On shutdown, `MusicServiceImpl.stop()` disposes the event subscriptions, clears the queues,
destroys every player and closes the client
(called from [`App.shutdown()`](architecture.md#shutdown-lifecycle)).
## Related commands
@@ -0,0 +1,43 @@
# Udio source — follow-up reference
These files are **not part of the build**. They are the original client-side Lavaplayer
implementation of the udio.com source, kept verbatim as the reference for a future port.
| File | Was |
|---|---|
| `UdioAudioSourceManager.java` | `me.bobert.netneutrality.music.source.UdioAudioSourceManager` |
| `UdioAudioTrack.java` | `me.bobert.netneutrality.music.source.UdioAudioTrack` |
| `UdioAudioSourceManagerTest.java` | its unit test |
## Why they were moved
The bot no longer decodes audio itself: after the [migration to Lavalink](../../music.md), source
resolution happens on the node. A Lavaplayer `AudioSourceManager` living in the bot process can
never be used by Lavalink, and these classes cannot even compile now that the `dev.arbjerg:lavaplayer`
dependency has been dropped from `pom.xml`.
## Status
**Udio playback is disabled.** `SourceFinder` still classifies Udio URLs, but
`SourceFinder.getIdentifier` returns no identifier for them and `MusicManager.play` replies that
Udio is temporarily unsupported.
## Follow-up
Port `UdioAudioSourceManager` / `UdioAudioTrack` into a standalone **Lavalink server plugin**
(its own build and published artifact), then enable it by adding the dependency to
`lavalink/application.yml`:
```yaml
lavalink:
plugins:
- dependency: "<group>:udio-plugin:<version>"
```
Once the node resolves `https://www.udio.com/songs/...` identifiers, the only bot-side change is to
restore `Udio` in the `SourceFinder.getIdentifier` switch and drop the "temporarily unsupported"
reply in `MusicManager.play`.
The scraping logic itself still applies: the manager extracts metadata and a direct playback URL
from the Next.js payload (`self.__next_f.push(...)`) of the track page, and the URL must be
re-fetched right before playback because it expires.
+2 -1
View File
@@ -37,7 +37,8 @@ startup by [`TaskManager.init()`](tasks.md#taskmanager)):
- `cancelAll()` — cancels every scheduled task; called first in
[`App.shutdown()`](architecture.md#shutdown-lifecycle).
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect`, and
The existing scheduled tasks are `MusicManagerCleanup`, `MusicPlayerAutoDisconnect` (which reads the
bot's gateway voice state rather than JDA's audio manager, see [music.md](music.md)), and
`TaskCleanup` (see the [task catalog](tasks.md#concrete-task-catalog)).
> **Direction of travel:** the scheduled-task abstraction is intended to eventually replace ad-hoc
+2 -1
View File
@@ -86,7 +86,8 @@ An example configuration file is `bot.properties.example`. Key configuration val
| `MYSQL_*` | Database connection settings (see [persistence.md](persistence.md)). |
| `NETNEUTRALITY_SENTRY_DSN` | Enables Sentry when present. |
| `GITLAB_HOST` / `GITLAB_TOKEN` / `GITLAB_PROJECT_ID` | GitLab issue reporting ([`ReportIssueTask`](tasks.md#worked-example--gitlab-bug-report-modal)). |
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube playback auth/cipher settings for the [music](music.md) source. |
| `NETNEUTRALITY_LAVALINK_URI` / `_PASSWORD` / `_NAME` | Connection settings for the [Lavalink node](music.md#settings). |
| `NETNEUTRALITY_PO_TOKEN` / `NETNEUTRALITY_VISITOR_DATA` / `NETNEUTRALITY_CIPHER_*` | YouTube auth/cipher settings, consumed by the node via `lavalink/application.yml`. |
## Utility helpers
+65
View File
@@ -0,0 +1,65 @@
server:
port: 2333
address: 0.0.0.0
lavalink:
plugins:
# Replacement for the in-process dev.lavalink.youtube:v2 source manager.
# Custom SABR-enabled fork (ashton045/youtube-source, feat/sabr-support) built by JitPack.
- dependency: "com.github.ashton045.youtube-source:youtube-plugin:33d50abd5f"
repository: "https://jitpack.io"
snapshot: false
server:
password: "${SERVER_PASSWORD:youshallnotpass}"
sources:
# Handled by the youtube-plugin below, must stay disabled here.
youtube: false
soundcloud: true
bandcamp: false
twitch: false
vimeo: false
nico: false
http: true
local: false
filters:
volume: true
bufferDurationMs: 400
frameBufferDurationMs: 5000
opusEncodingQuality: 10
resamplingQuality: LOW
trackStuckThresholdMs: 10000
useSeekGhosting: true
youtubePlaylistLoadLimit: 6
playerUpdateInterval: 5
youtubeSearchEnabled: true
soundcloudSearchEnabled: true
plugins:
youtube:
enabled: true
allowSearch: true
allowDirectVideoIds: true
allowDirectPlaylistIds: true
clients:
- TV
- MWEB
- WEB
- MUSIC
- WEBEMBEDDED
- ANDROID_VR
- TVHTML5_SIMPLY
oauth:
enabled: true
refreshToken: "${NETNEUTRALITY_OAUTH_TOKEN}"
remoteCipher:
url: "${NETNEUTRALITY_CIPHER_URL:http://ejs-api:8001}"
password: "${NETNEUTRALITY_CIPHER_PASSWORD:}"
remotePot:
url: "${NETNEUTRALITY_POT_URL:http://webpo:8080}"
logging:
level:
root: INFO
lavalink: DEBUG
# Set to INFO to see the OAuth flow instructions (device code / verification URL) in the terminal.
dev.lavalink.youtube.http.YoutubeOauth2Handler: INFO
+16 -27
View File
@@ -146,35 +146,11 @@
<artifactId>JDA</artifactId>
<version>6.4.1</version>
</dependency>
<!-- Lavalink v4 client; audio decoding happens on the remote node. -->
<dependency>
<groupId>dev.arbjerg</groupId>
<artifactId>lavaplayer</artifactId>
<version>2.2.7</version>
</dependency>
<dependency>
<groupId>dev.lavalink.youtube</groupId>
<artifactId>v2</artifactId>
<version>1.18.2</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-api</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-linux-x86-64</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-win-x86-64</artifactId>
<version>0.1.8</version>
</dependency>
<dependency>
<groupId>club.minnced</groupId>
<artifactId>jdave-native-darwin</artifactId>
<version>0.1.8</version>
<artifactId>lavalink-client</artifactId>
<version>3.4.0</version>
</dependency>
<dependency>
@@ -183,6 +159,12 @@
<version>5.3.2</version>
</dependency>
<dependency>
<groupId>io.javalin</groupId>
<artifactId>javalin</artifactId>
<version>6.7.0</version>
</dependency>
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-core</artifactId>
@@ -219,6 +201,13 @@
<groupId>org.gitlab4j</groupId>
<artifactId>gitlab4j-api</artifactId>
<version>6.2.0</version>
<exclusions>
<!-- Clashes with the Servlet 5 API used by Javalin's Jetty 11 in the shaded jar. -->
<exclusion>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
@@ -6,7 +6,7 @@ package me.bobert.netneutrality;
* https://discord.gg/qUa6zNFZsd
*/
import club.minnced.discord.jdave.interop.JDaveSessionFactory;
import dev.arbjerg.lavalink.libraries.jda.JDAVoiceUpdateListener;
import io.sentry.Sentry;
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.bootstrap.Lifecycle;
@@ -33,7 +33,6 @@ import me.bobert.netneutrality.utils.Settings;
import me.bobert.netneutrality.http.WebModule;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.JDABuilder;
import net.dv8tion.jda.api.audio.AudioModuleConfig;
import net.dv8tion.jda.api.entities.Activity;
import net.dv8tion.jda.api.exceptions.InvalidTokenException;
import net.dv8tion.jda.api.requests.GatewayIntent;
@@ -47,6 +46,7 @@ public class App {
private static App instance;
private final Settings settings;
private JDA jda;
private MusicService music;
private final ApplicationContext context = new ApplicationContext();
static void main() {
@@ -71,8 +71,7 @@ public class App {
if ("dev".equals(env)) {
Configurator.setLevel("net.dv8tion.jda", Level.DEBUG);
Configurator.setLevel("dev.lavalink.youtube", Level.DEBUG);
Configurator.setLevel("com.sedmelluq.discord", Level.DEBUG);
Configurator.setLevel("dev.arbjerg.lavalink", Level.DEBUG);
}
instance = new App(settings);
@@ -137,7 +136,7 @@ public class App {
JpaUtils.bindGuildServiceLookup(guildServices::get);
// Music sources (priority 40): initialise the shared player manager; disconnect on shutdown.
MusicService music = context.register(new MusicServiceImpl(settings, guildServices, tasks));
music = context.register(new MusicServiceImpl(settings, guildServices, tasks, executors));
BotState.bind(guildServices, music);
// JDA gateway; disconnected on shutdown.
@@ -200,8 +199,9 @@ public class App {
GatewayIntent.GUILD_MESSAGE_REACTIONS,
GatewayIntent.GUILD_VOICE_STATES,
GatewayIntent.MESSAGE_CONTENT)
.setAudioModuleConfig(
new AudioModuleConfig().withDaveSessionFactory(new JDaveSessionFactory()))
// Voice state/server updates are handed to Lavalink instead of JDA's audio stack;
// DAVE encryption is handled by the node.
.setVoiceDispatchInterceptor(new JDAVoiceUpdateListener(music.getClient()))
.setActivity(Activity.watching("me load..."))
.build();
jda.awaitReady();
@@ -23,13 +23,13 @@ import net.dv8tion.jda.api.interactions.InteractionHook;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Consumer;
import java.util.stream.Collectors;
@@ -41,7 +41,7 @@ public class TaskManagerImpl implements TaskManager {
private static final Logger logger = LogManager.getLogger(TaskManagerImpl.class);
private final TaskService taskService;
private final Map<String, Task> tasks;
private final List<Task> pendingTasks;
private final Map<String, Task> pendingTasks;
private final Map<String, CommandContext> deletableTasksContext;
private final Map<String, ReentrantLock> tasksLock;
private final ApplicationContext context;
@@ -55,7 +55,7 @@ public class TaskManagerImpl implements TaskManager {
public TaskManagerImpl(ApplicationContext context) {
Objects.requireNonNull(context, "ApplicationContext must not be null");
this.tasks = new ConcurrentHashMap<>();
this.pendingTasks = new CopyOnWriteArrayList<>();
this.pendingTasks = new ConcurrentHashMap<>();
this.deletableTasksContext = new ConcurrentHashMap<>();
this.tasksLock = new ConcurrentHashMap<>();
this.taskService = new TaskService(this);
@@ -86,7 +86,14 @@ public class TaskManagerImpl implements TaskManager {
@Override
public Task getTask(String id) {
return tasks.get(id);
if (id == null) {
return null;
}
Task task = tasks.get(id);
if (task != null) {
return task;
}
return pendingTasks.get(id);
}
/**
@@ -96,12 +103,17 @@ public class TaskManagerImpl implements TaskManager {
*/
@Override
public boolean hasTask(String id) {
return tasks.containsKey(id) || pendingTasks.stream().anyMatch(task -> task.getId().equals(id));
if (id == null) {
return false;
}
return tasks.containsKey(id) || pendingTasks.containsKey(id);
}
@Override
public Collection<Task> getTasks() {
return tasks.values();
List<Task> allTasks = new ArrayList<>(tasks.values());
allTasks.addAll(pendingTasks.values());
return allTasks;
}
@Override
@@ -123,7 +135,7 @@ public class TaskManagerImpl implements TaskManager {
}
if (task instanceof StepTask) {
pendingTasks.add(task);
pendingTasks.put(id, task);
} else {
tasks.put(id, task);
}
@@ -136,7 +148,7 @@ public class TaskManagerImpl implements TaskManager {
if (task instanceof WithChildren taskWithChildren) {
List<Task> childrens = taskWithChildren.getChildTasks();
if (childrens == null || childrens.isEmpty()) {
logger.warn("Parent task '{}' doesn't create any childs", task.getName());
logger.warn("Parent task '{}' doesn't create any children", task.getName());
return;
}
@@ -148,23 +160,37 @@ public class TaskManagerImpl implements TaskManager {
@Override
public void activateTask(Task task, String oldId) {
if (pendingTasks.remove(task)) {
if (task == null) {
return;
}
boolean removed = oldId != null && pendingTasks.remove(oldId) != null;
if (!removed) {
removed = pendingTasks.remove(task.getId()) != null;
}
if (!removed) {
pendingTasks.values().remove(task);
}
tasks.put(task.getId(), task);
logger.info("Updating task {} with new id: {} (was: {})",
task.getName(), task.getId(), oldId);
}
}
@Override
public void removeTask(Task task) {
if (task == null) {
return;
}
String id = task.getId();
if (tasks.remove(id) != null) {
boolean removed = tasks.remove(id) != null;
removed |= pendingTasks.remove(id) != null;
removed |= pendingTasks.values().remove(task);
if (removed) {
logger.debug("Removing task: {}", id);
ReentrantLock lock = tasksLock.remove(task.getLockId());
if (lock != null) {
logger.debug("Cleaning up lock for removed task: {}", task.getId());
if (task instanceof PersistableTask persistableTask) {
taskService.unpersistTask(persistableTask);
}
}
}
@@ -186,11 +212,11 @@ public class TaskManagerImpl implements TaskManager {
try {
if (lock != null) {
logger.debug("Aquiring lock for task: {}", taskId);
logger.debug("Acquiring lock for task: {}", taskId);
lock.lock();
if (!tasks.containsKey(taskId)) {
logger.debug("Task {} is no longer available after aquiring lock", taskId);
logger.debug("Task {} is no longer available after acquiring lock", taskId);
return;
}
}
@@ -224,16 +250,25 @@ public class TaskManagerImpl implements TaskManager {
@Override
public <E extends Task> Optional<E> findTaskByPartialId(Class<E> clazz, String partialId) {
List<E> tasks = findTasksByPartialId(clazz, partialId);
return Optional.ofNullable(tasks.isEmpty() ? null : tasks.getFirst());
if (clazz == null || partialId == null) {
return Optional.empty();
}
return tasks.values().stream()
.filter(clazz::isInstance)
.map(clazz::cast)
.filter(task -> task.getId() != null && task.getId().contains(partialId))
.findFirst();
}
@Override
public <E extends Task> List<E> findTasksByPartialId(Class<E> clazz, String partialId) {
if (clazz == null || partialId == null) {
return List.of();
}
return tasks.values().stream()
.filter(clazz::isInstance)
.map(clazz::cast)
.filter(task -> task.getId().contains(partialId))
.filter(task -> task.getId() != null && task.getId().contains(partialId))
.collect(Collectors.toList());
}
@@ -272,6 +307,9 @@ public class TaskManagerImpl implements TaskManager {
private void deleteInteraction(Task task) {
if (task == null) {
return;
}
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
@@ -289,18 +327,16 @@ public class TaskManagerImpl implements TaskManager {
private void deleteInteraction(String taskId, Deletable deletable) {
logger.debug("Deleting interaction: {}", taskId);
CommandContext context = deletableTasksContext.get(taskId);
if (context != null) {
deletableTasksContext.remove(taskId);
InteractionHook hook = context.getInteractionHook();
CommandContext commandContext = deletableTasksContext.remove(taskId);
if (commandContext != null) {
InteractionHook hook = commandContext.getInteractionHook();
if (hook != null) {
if (hook.isExpired()) {
context.replyEphemeral("This interaction has expired, can't remove it");
}
if (!hook.isExpired()) {
hook.deleteOriginal().queue();
commandContext.replyEphemeral("This interaction has expired, can't remove it");
} else {
hook.deleteOriginal().queue(null, error ->
logger.warn("Failed to delete interaction for task {}: {}", taskId, error.getMessage())
);
}
}
}
@@ -319,12 +355,32 @@ public class TaskManagerImpl implements TaskManager {
break;
case REMOVE_SELF_AND_CHILDREN:
if (task instanceof WithChildren taskWithChildren) {
for (Task child : taskWithChildren.getChildTasks()) {
if (task instanceof WithParent<?> taskWithParent) {
Task parent = taskWithParent.getParentTask();
if (parent != null) {
if (parent instanceof WithChildren parentWithChildren) {
List<Task> childTasks = parentWithChildren.getChildTasks();
if (childTasks != null) {
for (Task child : childTasks) {
removeTask(child);
}
}
}
removeTask(parent);
} else {
removeTask(task);
}
} else if (task instanceof WithChildren taskWithChildren) {
List<Task> childTasks = taskWithChildren.getChildTasks();
if (childTasks != null) {
for (Task child : childTasks) {
removeTask(child);
}
}
removeTask(task);
} else {
removeTask(task);
}
break;
case KEEP:
@@ -0,0 +1,65 @@
package me.bobert.netneutrality.http;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* Small LRU cache holding responses of outbound {@link WebModule} GET requests.
*/
final class ResponseCache {
private static final int MAX_ENTRIES = 50;
private static final Map<String, CachedResponse> entries = Collections.synchronizedMap(
new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CachedResponse> eldest) {
return size() > MAX_ENTRIES;
}
}
);
private ResponseCache() {
}
/**
* Returns the cached response for the key when it is still valid.
*
* @param key the cache key
* @return the cached response, or empty when absent or expired
*/
static Optional<CachedResponse> get(String key) {
CachedResponse cached = entries.get(key);
return cached != null && !cached.isExpired() ? Optional.of(cached) : Optional.empty();
}
static void put(String key, CachedResponse response) {
entries.put(key, response);
}
static void invalidate(String url) {
entries.keySet().removeIf(key -> key.contains(url));
}
/**
* A response body kept alongside its status code until {@code expirationTime}.
*/
record CachedResponse(int statusCode, String body, long expirationTime) {
boolean isExpired() {
return System.currentTimeMillis() > expirationTime;
}
Optional<String> getBody() {
return Optional.ofNullable(body);
}
}
}
@@ -9,7 +9,7 @@ package me.bobert.netneutrality.http;
import com.google.gson.Gson;
import io.sentry.Breadcrumb;
import io.sentry.Sentry;
import okhttp3.Headers;
import me.bobert.netneutrality.http.ResponseCache.CachedResponse;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
@@ -21,11 +21,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.TimeUnit;
public class WebModule {
@@ -34,20 +31,13 @@ public class WebModule {
}
private static final String USER_AGENT = "Net Neutrality/%s (Discord bot, discord.gg/trAqhQ5Wsr)";
private static final OkHttpClient CLIENT = new OkHttpClient();
private static volatile String defaultVersion = "0.0.0";
private static final Logger log = LoggerFactory.getLogger(WebModule.class);
public static void setDefaultVersion(String version) {
defaultVersion = version;
}
private static final Map<String, CachedResponse> cache = Collections.synchronizedMap(
new LinkedHashMap<>(100, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, CachedResponse> eldest) {
return size() > 50; // Limit cache to 50 entries
}
}
);
private final Method method;
private final String url;
@@ -62,7 +52,7 @@ public class WebModule {
this.url = builder.url;
this.client = builder.client;
this.headers = builder.headers;
this.cacheExpirationMillis = builder.timeUnit.toMillis(builder.timeout);
this.cacheExpirationMillis = builder.timeUnit.toMillis(builder.cacheTimeout);
this.request = builder.request;
}
@@ -74,7 +64,7 @@ public class WebModule {
* @param url the URL of the cached response to invalidate
*/
public static void invalidateCache(String url) {
cache.keySet().removeIf(s -> s.contains(url));
ResponseCache.invalidate(url);
}
/**
@@ -86,60 +76,49 @@ public class WebModule {
* POST requests do not support caching.
*
* @return the {@code WebModule} instance, allowing further interaction with the response
* @throws RuntimeException if the request fails
*/
public WebModule request() {
String cacheKey = createCacheKey(method, url, headers);
if (method == Method.GET && cacheKey != null) {
CachedResponse cachedResponse = cache.get(cacheKey);
if (cacheKey != null) {
CachedResponse cached = ResponseCache.get(cacheKey).orElse(null);
if (cachedResponse != null && !cachedResponse.isExpired()) {
if (cached != null) {
log.debug("Retrieving cached response: {}", cacheKey);
this.cachedResponse = cachedResponse;
this.cachedResponse = cached;
return this;
}
}
long startTime = System.currentTimeMillis();
try (Response response = client.newCall(request).execute()) {
long elapsedTime = System.currentTimeMillis() - startTime;
Sentry.addBreadcrumb(Breadcrumb.http(url, method.name(), response.code()));
String body = response.body().string();
this.cachedResponse = new CachedResponse(
response.code(),
response.headers(),
elapsedTime,
body,
System.currentTimeMillis() + cacheExpirationMillis,
null
response.body().string(),
System.currentTimeMillis() + cacheExpirationMillis
);
} catch (IOException e) {
long elapsedTime = System.currentTimeMillis() - startTime;
log.error("Request failed: {}", e.getMessage(), e);
Sentry.captureException(e);
this.cachedResponse = new CachedResponse(
0, // Status code 0 indicates a failure
new Headers.Builder().build(),
elapsedTime,
null,
System.currentTimeMillis() + cacheExpirationMillis,
e.getMessage()
System.currentTimeMillis() + cacheExpirationMillis
);
}
if (method == Method.GET && cacheKey != null) {
cache.put(cacheKey, this.cachedResponse);
if (cacheKey != null) {
ResponseCache.put(cacheKey, this.cachedResponse);
}
return this;
}
private String createCacheKey(Method method, String url, Map<String, String> headers) {
if (method == Method.POST) {
return null; // no cache for post requests
if (method != Method.GET) {
return null; // only GET requests are cached
}
StringBuilder keyBuilder = new StringBuilder(method.name()).append(":").append(url);
@@ -176,12 +155,9 @@ public class WebModule {
private final String url;
private final String version;
private Method method = Method.GET;
private long timeout = 5;
private long cacheTimeout = 5;
private TimeUnit timeUnit = TimeUnit.MINUTES;
private OkHttpClient client;
private long connectTimeout;
private long readTimeout;
private long writeTimeout;
private OkHttpClient client = CLIENT;
private final Map<String, String> headers = new HashMap<>();
private final Map<String, String> params = new HashMap<>();
@@ -242,51 +218,24 @@ public class WebModule {
* @return the current {@code Builder} instance for chaining
*/
public Builder cache(long timeout, TimeUnit timeUnit) {
this.timeout = timeout;
this.cacheTimeout = timeout;
this.timeUnit = timeUnit;
return this;
}
/**
* Sets the connect, read and write timeouts of the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder timeout(long timeout, TimeUnit timeUnit) {
this.connectTimeout = timeUnit.toMillis(timeout);
this.readTimeout = timeUnit.toMillis(timeout);
this.writeTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the connection timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder connectTimeout(long timeout, TimeUnit timeUnit) {
this.connectTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the read timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder readTimeout(long timeout, TimeUnit timeUnit) {
this.readTimeout = timeUnit.toMillis(timeout);
return this;
}
/**
* Sets the write timeout for the HTTP client.
*
* @param timeout the timeout duration
* @param timeUnit the unit of time for the timeout
* @return the current {@code Builder} instance for chaining
*/
public Builder writeTimeout(long timeout, TimeUnit timeUnit) {
this.writeTimeout = timeUnit.toMillis(timeout);
this.client = CLIENT.newBuilder()
.connectTimeout(timeout, timeUnit)
.readTimeout(timeout, timeUnit)
.writeTimeout(timeout, timeUnit)
.build();
return this;
}
@@ -317,30 +266,9 @@ public class WebModule {
* @return a new {@code WebModule} instance
*/
public WebModule build() {
this.client = new OkHttpClient.Builder()
.connectTimeout(connectTimeout, TimeUnit.MILLISECONDS)
.writeTimeout(writeTimeout, TimeUnit.MILLISECONDS)
.readTimeout(readTimeout, TimeUnit.MILLISECONDS)
.build();
this.request = createRequest();
return new WebModule(this);
}
}
private record CachedResponse(int statusCode, Headers headers, long elapsedTime, String body, long expirationTime,
String errorMessage) {
public boolean isExpired() {
return System.currentTimeMillis() > expirationTime;
}
public Optional<String> getBody() {
return Optional.ofNullable(body);
}
public Optional<String> getErrorMessage() {
return Optional.ofNullable(errorMessage);
}
}
}
@@ -1,33 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpHandler;
import java.io.IOException;
import java.io.OutputStream;
public abstract class AbstractHandler implements HttpHandler {
public abstract void handle(HttpExchange exchange) throws IOException;
public void sendStringResponse(HttpExchange exchange, String str) throws IOException {
exchange.sendResponseHeaders(200, str.length());
OutputStream os = exchange.getResponseBody();
os.write(str.getBytes());
os.close();
}
public void sendJsonResponse(HttpExchange exchange, String str) throws IOException {
exchange.getResponseHeaders().add("Content-Type", "application/json");
exchange.sendResponseHeaders(200, str.length());
OutputStream os = exchange.getResponseBody();
os.write(str.getBytes());
os.close();
}
}
@@ -1,97 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.google.gson.Gson;
import com.sun.net.httpserver.HttpExchange;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildService;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
import java.util.TreeMap;
import java.util.function.Supplier;
public class ActionHandler extends AbstractHandler {
private final Supplier<JDA> jdaSupplier;
private final GuildServiceProvider guildServices;
private final Runnable shutdownHook;
public ActionHandler(Supplier<JDA> jdaSupplier, GuildServiceProvider guildServices, Runnable shutdownHook) {
this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier");
this.guildServices = Objects.requireNonNull(guildServices, "guildServices");
this.shutdownHook = Objects.requireNonNull(shutdownHook, "shutdownHook");
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getRawPath();
if (path.equals("/actions/shutdown")) {
LogManager.getLogger(ActionHandler.class).info("Request to shutdown received");
shutdownHook.run();
sendStringResponse(exchange, "Shutting down..");
System.exit(0);
return;
}
if (path.equals("/actions/status")) {
TreeMap<String, String> json = new TreeMap<>();
JDA jda = jdaSupplier.get();
json.put("health", String.valueOf(jda.getStatus()));
json.put("ping", String.valueOf(jda.getGatewayPing()));
sendJsonResponse(exchange, new Gson().toJson(json));
return;
}
if (path.startsWith("/actions/settings/current/")) {
JSONObject json = new JSONObject();
String[] args = exchange.getRequestURI().toString().split("/");
String guildId = args[args.length - 1];
Guild guild = jdaSupplier.get().getGuildById(guildId);
if (guild != null) {
GuildService service = guildServices.get(guild);
Map<String, String> settings = service.getGuildSettingService().getSettings();
JSONObject setting = new JSONObject(settings);
json.put("status", "ok");
json.put("settings", setting);
} else {
json.put("status", "error");
}
sendJsonResponse(exchange, json.toString());
return;
}
if (path.startsWith("/actions/settings/reload/")) {
TreeMap<String, String> json = new TreeMap<>();
String[] args = exchange.getRequestURI().toString().split("/");
String guildId = args[args.length - 1];
boolean ok = true; //TODO: add bool
guildServices.get(guildId).getGuildSettingService().reloadSettingsCache();
json.put("status", String.valueOf(ok));
sendJsonResponse(exchange, new Gson().toJson(json));
return;
}
sendStringResponse(exchange, path);
}
}
@@ -0,0 +1,82 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.Javalin;
import io.javalin.http.Handler;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the {@code /actions/*} endpoints on a {@link Javalin} instance.
*/
public final class ActionRoutes {
private static final Logger logger = LogManager.getLogger(ActionRoutes.class);
private static final long SHUTDOWN_DELAY_MILLIS = 500;
private ActionRoutes() {
}
public static void register(
Javalin app,
Supplier<JDA> jdaSupplier,
GuildServiceProvider guildServices,
Runnable shutdownHook
) {
app.get("/actions/status", ctx -> {
JDA jda = GuildRoutes.jda(jdaSupplier);
ctx.json(Map.of(
"health", String.valueOf(jda.getStatus()),
"ping", String.valueOf(jda.getGatewayPing())
));
});
Handler shutdown = ctx -> {
logger.info("Request to shutdown received");
ctx.result("Shutting down..");
scheduleShutdown(shutdownHook);
};
app.post("/actions/shutdown", shutdown);
app.get("/actions/shutdown", shutdown);
app.get("/actions/settings/current/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
Map<String, String> settings = guildServices.get(guild).getGuildSettingService().getSettings();
ctx.json(Map.of("status", "ok", "settings", settings));
});
app.post("/actions/settings/reload/{guildId}", ctx -> {
Guild guild = GuildRoutes.guild(jdaSupplier, ctx);
guildServices.get(guild).getGuildSettingService().reloadSettingsCache();
ctx.json(Map.of("status", "true"));
});
}
/**
* Runs the shutdown hook off the request thread so the response can be flushed first.
*/
private static void scheduleShutdown(Runnable shutdownHook) {
Thread.ofPlatform().name("web-api-shutdown").start(() -> {
try {
Thread.sleep(SHUTDOWN_DELAY_MILLIS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
shutdownHook.run();
System.exit(0);
});
}
}
@@ -0,0 +1,33 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.google.gson.Gson;
import io.javalin.json.JsonMapper;
import org.jetbrains.annotations.NotNull;
import java.lang.reflect.Type;
/**
* Javalin {@link JsonMapper} backed by a shared {@link Gson} instance.
*/
public class GsonJsonMapper implements JsonMapper {
private static final Gson GSON = new Gson();
@NotNull
@Override
public String toJsonString(@NotNull Object obj, @NotNull Type type) {
return GSON.toJson(obj, type);
}
@NotNull
@Override
public <T> T fromJsonString(@NotNull String json, @NotNull Type targetType) {
return GSON.fromJson(json, targetType);
}
}
@@ -1,100 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpExchange;
import net.dv8tion.jda.api.JDA;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Objects;
import java.util.function.Supplier;
public class GuildHandler extends AbstractHandler {
private final Supplier<JDA> jdaSupplier;
public GuildHandler(Supplier<JDA> jdaSupplier) {
this.jdaSupplier = Objects.requireNonNull(jdaSupplier, "jdaSupplier");
}
@Override
public void handle(HttpExchange exchange) throws IOException {
String path = exchange.getRequestURI().getRawPath();
if (path.equals("/guilds/owner")) {
JSONArray array = new JSONArray();
JDA jda = jdaSupplier.get();
jda.getGuilds().forEach(guild -> {
JSONObject obj = new JSONObject();
obj.put("guild_id", guild.getId());
obj.put("owner_id", guild.getOwnerId());
array.put(obj);
});
sendJsonResponse(exchange, array.toString());
return;
}
if (path.startsWith("/guilds/channels/")) {
String[] args = exchange.getRequestURI().toString().split("/");
String guild = args[args.length - 1];
JSONArray array = new JSONArray();
JDA jda = jdaSupplier.get();
if (jda.getGuildById(guild) == null) {
sendStringResponse(exchange, path);
return;
}
jda.getGuildById(guild).getTextChannels().forEach(channel -> {
JSONObject obj = new JSONObject();
obj.put("channel_id", channel.getId());
obj.put("channel_name", channel.getName());
array.put(obj);
});
sendJsonResponse(exchange, array.toString());
return;
}
if (path.startsWith("/guilds/roles/")) {
String[] args = exchange.getRequestURI().toString().split("/");
String guild = args[args.length - 1];
JSONArray array = new JSONArray();
JDA jda = jdaSupplier.get();
if (jda.getGuildById(guild) == null) {
sendStringResponse(exchange, path);
return;
}
jda.getGuildById(guild).getRoles().forEach(role -> {
JSONObject obj = new JSONObject();
obj.put("channel_id", role.getId());
obj.put("channel_name", role.getName());
array.put(obj);
});
sendJsonResponse(exchange, array.toString());
return;
}
sendStringResponse(exchange, path);
}
}
@@ -0,0 +1,79 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.Javalin;
import io.javalin.http.Context;
import io.javalin.http.NotFoundResponse;
import io.javalin.http.ServiceUnavailableResponse;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.function.Supplier;
/**
* Registers the read-only {@code /guilds/*} endpoints on a {@link Javalin} instance.
*/
public final class GuildRoutes {
private GuildRoutes() {
}
public static void register(Javalin app, Supplier<JDA> jdaSupplier) {
app.get("/guilds/owner", ctx -> ctx.json(
jda(jdaSupplier).getGuilds().stream()
.map(guild -> ordered("guild_id", guild.getId(), "owner_id", guild.getOwnerId()))
.toList()
));
app.get("/guilds/channels/{guildId}", ctx -> ctx.json(
guild(jdaSupplier, ctx).getTextChannels().stream()
.map(channel -> ordered("channel_id", channel.getId(), "channel_name", channel.getName()))
.toList()
));
app.get("/guilds/roles/{guildId}", ctx -> ctx.json(
guild(jdaSupplier, ctx).getRoles().stream()
.map(role -> ordered("channel_id", role.getId(), "channel_name", role.getName()))
.toList()
));
}
/**
* Builds a two entry map with a stable key order for JSON serialization.
*/
private static Map<String, String> ordered(String firstKey, String firstValue, String secondKey, String secondValue) {
Map<String, String> map = new LinkedHashMap<>();
map.put(firstKey, firstValue);
map.put(secondKey, secondValue);
return map;
}
/**
* Resolves the JDA client or fails with 503 while it is not ready yet.
*/
static JDA jda(Supplier<JDA> jdaSupplier) {
JDA jda = jdaSupplier.get();
if (jda == null) {
throw new ServiceUnavailableResponse("Discord client is not ready");
}
return jda;
}
/**
* Resolves the {@code guildId} path parameter or fails with 404.
*/
static Guild guild(Supplier<JDA> jdaSupplier, Context ctx) {
Guild guild = jda(jdaSupplier).getGuildById(ctx.pathParam("guildId"));
if (guild == null) {
throw new NotFoundResponse("Unknown guild");
}
return guild;
}
}
@@ -1,22 +0,0 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpExchange;
import java.io.IOException;
public class RootHandler extends AbstractHandler {
@Override
public void handle(HttpExchange exchange) throws IOException {
String response = "<h1>Server start success if you see this message</h1>";
sendStringResponse(exchange, response);
}
}
@@ -6,20 +6,16 @@ package me.bobert.netneutrality.http.server;
* https://discord.gg/qUa6zNFZsd
*/
import com.sun.net.httpserver.HttpServer;
import io.javalin.Javalin;
import io.javalin.http.HttpResponseException;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import me.bobert.netneutrality.core.thread.BaseThreadFactory;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.IntSupplier;
import java.util.Map;
import java.util.function.Supplier;
/**
@@ -28,13 +24,13 @@ import java.util.function.Supplier;
public class WebApi implements Lifecycle {
private static final Logger logger = LogManager.getLogger(WebApi.class);
private static final String LIVENESS_HTML = "<h1>Server start success if you see this message</h1>";
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final Supplier<JDA> jdaSupplier;
private final Runnable shutdownHook;
private HttpServer server;
private ExecutorService executor;
private Javalin app;
public WebApi(
SettingsProvider settings,
@@ -59,33 +55,36 @@ public class WebApi implements Lifecycle {
int port = Integer.parseInt(settings.get("NETNEUTRALITY_API_PORT", "9000"));
logger.info("Starting web server...");
try {
InetSocketAddress sockAddr = new InetSocketAddress(port);
server = HttpServer.create(sockAddr, 0);
} catch (IOException e) {
logger.error("Failed to create HTTP server", e);
return;
}
app = Javalin.create(config -> {
config.jsonMapper(new GsonJsonMapper());
config.showJavalinBanner = false;
});
server.createContext("/", new RootHandler());
server.createContext("/actions", new ActionHandler(jdaSupplier, guildServices, shutdownHook));
server.createContext("/guilds", new GuildHandler(jdaSupplier));
executor = Executors.newFixedThreadPool(10, new BaseThreadFactory("web-api"));
server.setExecutor(executor);
server.start();
logger.info("Web server started at [{}]", server.getAddress());
app.get("/", ctx -> ctx.html(LIVENESS_HTML));
GuildRoutes.register(app, jdaSupplier);
ActionRoutes.register(app, jdaSupplier, guildServices, shutdownHook);
app.exception(HttpResponseException.class, (e, ctx) ->
ctx.status(e.getStatus()).json(Map.of("status", "error", "message", e.getMessage()))
);
app.exception(Exception.class, (e, ctx) -> {
logger.error("Unhandled error while serving [{}]", ctx.path(), e);
ctx.status(500).json(Map.of("status", "error"));
});
app.error(404, ctx -> ctx.json(Map.of("status", "error", "message", "Not found")));
app.start(port);
logger.info("Web server started on port [{}]", port);
}
@Override
public void stop() {
if (server != null) {
if (app != null) {
logger.info("Stopping web server...");
server.stop(0);
server = null;
}
if (executor != null) {
executor.shutdownNow();
executor = null;
app.stop();
app = null;
}
}
}
@@ -1,45 +0,0 @@
package me.bobert.netneutrality.music;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.playback.AudioFrame;
import net.dv8tion.jda.api.audio.AudioSendHandler;
import java.nio.ByteBuffer;
/**
* This is a wrapper around AudioPlayer which makes it behave as an AudioSendHandler for JDA. As JDA calls canProvide
* before every call to provide20MsAudio(), we pull the frame in canProvide() and use the frame we already pulled in
* provide20MsAudio().
*/
public class AudioPlayerSendHandler implements AudioSendHandler {
private final AudioPlayer audioPlayer;
private AudioFrame lastFrame;
/**
* @param audioPlayer Audio player to wrap.
*/
public AudioPlayerSendHandler(AudioPlayer audioPlayer) {
this.audioPlayer = audioPlayer;
}
@Override
public boolean canProvide() {
lastFrame = audioPlayer.provide();
return lastFrame != null;
}
@Override
public ByteBuffer provide20MsAudio() {
return ByteBuffer.wrap(lastFrame.getData());
}
@Override
public boolean isOpus() {
return true;
}
}
@@ -6,51 +6,57 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.utils.MusicUtils;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.Link;
import dev.arbjerg.lavalink.client.player.LavalinkPlayer;
import dev.arbjerg.lavalink.client.player.PlayerUpdateBuilder;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.db.GSettings;
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.GuildVoiceState;
import net.dv8tion.jda.api.entities.channel.middleman.AudioChannel;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import java.util.function.Consumer;
/**
* Per-guild music player and queue owner.
*
* <p>The shared {@link AudioPlayerManager} and the manager cache live on {@link MusicServiceImpl}.
* <p>Playback is delegated to the Lavalink node through the {@link Link} of this guild; the
* shared {@link LavalinkClient} and the manager cache live on {@link MusicServiceImpl}.
* Obtain instances via {@link MusicService#get(Guild)}.
*
* <p>All Lavalink operations return reactive {@code Mono}s. This class subscribes to them
* internally so that the command layer keeps its synchronous call style; failures are reported
* through {@link LogHandler}.
*/
public class MusicManager implements MusicManagerCallback {
private static final Logger logger = LogManager.getLogger(MusicManager.class);
static final int DEFAULT_VOLUME = 50;
private final Guild guild;
private final AudioPlayerManager playerManager;
private final AudioPlayer player;
private final LavalinkClient client;
private final TrackScheduler scheduler;
private final int baseVolume;
private final GuildService guildService;
private final TaskManager taskManager;
private final VoteSkipService voteSkipService;
MusicManager(Guild guild, AudioPlayerManager playerManager, GuildService guildService, TaskManager taskManager) {
MusicManager(Guild guild, LavalinkClient client, GuildService guildService, TaskManager taskManager) {
this.guild = guild;
this.playerManager = playerManager;
this.player = playerManager.createPlayer();
this.client = client;
this.scheduler = new TrackScheduler(this);
this.guildService = guildService;
this.taskManager = taskManager;
this.voteSkipService = new VoteSkipService();
this.baseVolume = parseBaseVolume(guildService);
guild.getAudioManager().setSendingHandler(new AudioPlayerSendHandler(player));
player.addListener(scheduler);
}
public GuildService getGuildService() {
@@ -69,50 +75,101 @@ public class MusicManager implements MusicManagerCallback {
return scheduler.getQueueManager();
}
public AudioPlayer getPlayer() {
return player;
}
public Guild getGuild() {
return guild;
}
/**
* Returns the {@link Link} binding this guild to a Lavalink node, or {@code null} when no
* node is currently available.
*
* @return the link or {@code null}
*/
public Link getLink() {
try {
return client.getOrCreateLink(guild.getIdLong());
} catch (RuntimeException e) {
logger.error("Guild {}: no Lavalink node available", guild.getId(), e);
return null;
}
}
/**
* Returns the voice channel the bot is currently connected to in this guild.
*
* <p>With Lavalink the voice connection is owned by the node, so JDA's audio manager cannot
* be consulted; the gateway voice state of the self member is the source of truth.
*
* @return the connected channel or {@code null}
*/
public AudioChannel getConnectedChannel() {
GuildVoiceState state = guild.getSelfMember().getVoiceState();
return state == null ? null : state.getChannel();
}
public synchronized boolean isNotConnectedTo(AudioChannel vChannel) {
AudioChannel current = guild.getAudioManager().getConnectedChannel();
AudioChannel current = getConnectedChannel();
return vChannel == null || current == null || !vChannel.getId().equals(current.getId());
}
public synchronized void connect(AudioChannel audioChannel) {
if (audioChannel != null) {
logger.debug("Guild {}: connecting to voice channel {}", audioChannel.getGuild().getId(), audioChannel.getId());
guild.getAudioManager().openAudioConnection(audioChannel);
guild.getAudioManager().setSelfDeafened(true);
player.setVolume(baseVolume);
} else {
if (audioChannel == null) {
throw new IllegalArgumentException("audioChannel must not be null");
}
logger.debug("Guild {}: connecting to voice channel {}", audioChannel.getGuild().getId(), audioChannel.getId());
// Only the self-deaf flag is read from the audio manager; the connection itself is opened
// through the direct audio controller so that Lavalink handles the voice websocket.
guild.getAudioManager().setSelfDeafened(true);
guild.getJDA().getDirectAudioController().connect(audioChannel);
setVolume(baseVolume);
}
public synchronized void disconnect() {
if (guild.getAudioManager().getConnectedChannel() != null) {
logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), guild.getAudioManager().getConnectedChannel().getId());
player.stopTrack();
guild.getAudioManager().closeAudioConnection();
if (getConnectedChannel() != null) {
logger.debug("Guild {}: disconnecting from voice channel {}", guild.getId(), getConnectedChannel().getId());
stopTrack();
guild.getJDA().getDirectAudioController().disconnect(guild);
}
scheduler.detachContext();
}
public synchronized boolean isConnected() {
return guild.getAudioManager().getConnectedChannel() != null;
/**
* Destroys the Lavalink player of this guild, used on shutdown.
*/
synchronized void destroy() {
Link link = getLink();
if (link != null) {
link.destroy().subscribe(
ignored -> { },
error -> logger.error("Guild {}: failed to destroy player", guild.getId(), error)
);
}
}
public synchronized AudioTrack getCurrentlyPlaying() {
return player.getPlayingTrack();
public synchronized boolean isConnected() {
return getConnectedChannel() != null;
}
public synchronized Track getCurrentlyPlaying() {
return scheduler.getCurrentTrack();
}
/**
* Returns the playback position of the currently playing track, in milliseconds.
*
* @return the position, or {@code 0} when nothing is playing
*/
public long getCurrentPosition() {
Link link = getLink();
LavalinkPlayer player = link == null ? null : link.getCachedPlayer();
return player == null ? 0L : player.getPosition();
}
public synchronized boolean isCurrentlyPlaying() {
return player.getPlayingTrack() != null;
return scheduler.getCurrentTrack() != null;
}
public synchronized void play(String query, CommandContext context) {
@@ -122,21 +179,41 @@ public class MusicManager implements MusicManagerCallback {
public synchronized void play(String query, CommandContext context, boolean silent) {
logger.debug("Guild {}: request to play '{}' by user {}", guild.getId(), query, context.getGuildUser().getId());
if (MusicUtils.canConnect(context, this)) {
if (!MusicUtils.canConnect(context, this)) {
return;
}
SourceFinder.AudioSourceType type = SourceFinder.getSource(query);
if (type == SourceFinder.AudioSourceType.Udio) {
context.reply("Udio playback is temporarily unsupported since the migration to Lavalink");
return;
}
String identifier = SourceFinder.getIdentifier(query);
if (identifier == null) {
context.reply("No source found to play the given url");
return;
}
TrackLoadingHandler loadingHandler = new TrackLoadingHandler(context, this, identifier, type, silent);
playerManager.loadItemOrdered(this, identifier, loadingHandler);
Link link = getLink();
if (link == null) {
context.reply("The audio node is currently unavailable, please try again later");
return;
}
link.loadItem(identifier).subscribe(
new TrackLoadingHandler(context, this, identifier, type, silent),
error -> {
LogHandler.log(new RuntimeException("Failed to load track: " + error.getMessage(), error));
if (!silent) {
context.reply("Failed to load the requested track");
}
}
);
}
public synchronized void stopTrack() {
player.stopTrack();
updatePlayer("stop track", builder -> builder.stopTrack());
}
public synchronized CommandExecutionResult resume() {
@@ -144,12 +221,11 @@ public class MusicManager implements MusicManagerCallback {
}
public synchronized CommandExecutionResult voteSkip(CommandContext context) {
AudioChannel audioChannel = guild.getAudioManager().getConnectedChannel();
return voteSkipService.voteSkip(context, getQueueManager(), audioChannel, isCurrentlyPlaying());
return voteSkipService.voteSkip(context, getQueueManager(), getConnectedChannel(), isCurrentlyPlaying());
}
public void setVolume(int volume) {
player.setVolume(volume);
updatePlayer("set volume", builder -> builder.setVolume(volume));
}
public int getQueueSize() {
@@ -161,6 +237,26 @@ public class MusicManager implements MusicManagerCallback {
disconnect();
}
/**
* Applies a player update on the node and subscribes to it, logging any failure.
*
* @param description what the update does, used for logging
* @param mutation the builder mutation to apply
*/
void updatePlayer(String description, Consumer<PlayerUpdateBuilder> mutation) {
Link link = getLink();
if (link == null) {
return;
}
PlayerUpdateBuilder builder = link.createOrUpdatePlayer();
mutation.accept(builder);
builder.subscribe(
ignored -> { },
error -> logger.error("Guild {}: failed to {}", guild.getId(), description, error)
);
}
private static int parseBaseVolume(GuildService guildService) {
try {
return Integer.parseInt(guildService.getGuildSettingService().getSetting(GSettings.MUSIC_VOLUME));
@@ -6,15 +6,16 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import dev.arbjerg.lavalink.client.LavalinkClient;
import java.util.Collection;
import me.bobert.netneutrality.bootstrap.Lifecycle;
import net.dv8tion.jda.api.entities.Guild;
/**
* Owns the shared audio player manager and the per-guild {@link MusicManager} cache.
* Owns the shared {@link LavalinkClient} and the per-guild {@link MusicManager} cache.
*
* <p>As a {@link Lifecycle} component it registers audio sources on {@link #start()} and
* disconnects every player on {@link #stop()}.
* <p>As a {@link Lifecycle} component it registers the Lavalink node on {@link #start()} and
* destroys every player on {@link #stop()}.
*/
public interface MusicService extends Lifecycle {
@@ -32,4 +33,11 @@ public interface MusicService extends Lifecycle {
* @return the managers
*/
Collection<MusicManager> getManagers();
/**
* Returns the shared Lavalink client used for voice dispatch interception and player control.
*
* @return the client
*/
LavalinkClient getClient();
}
@@ -6,41 +6,66 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.player.DefaultAudioPlayerManager;
import com.sedmelluq.discord.lavaplayer.source.http.HttpAudioSourceManager;
import com.sedmelluq.discord.lavaplayer.source.soundcloud.SoundCloudAudioSourceManager;
import dev.lavalink.youtube.YoutubeAudioSourceManager;
import dev.lavalink.youtube.YoutubeSourceOptions;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import dev.arbjerg.lavalink.client.Helpers;
import dev.arbjerg.lavalink.client.LavalinkClient;
import dev.arbjerg.lavalink.client.LavalinkNode;
import dev.arbjerg.lavalink.client.NodeOptions;
import dev.arbjerg.lavalink.client.event.ReadyEvent;
import dev.arbjerg.lavalink.client.event.TrackEndEvent;
import dev.arbjerg.lavalink.client.event.TrackExceptionEvent;
import dev.arbjerg.lavalink.client.event.TrackStartEvent;
import dev.arbjerg.lavalink.client.event.TrackStuckEvent;
import dev.arbjerg.lavalink.client.event.WebSocketClosedEvent;
import dev.arbjerg.lavalink.internal.LavalinkSocket;
import me.bobert.netneutrality.core.tasks.TaskManager;
import me.bobert.netneutrality.music.source.UdioAudioSourceManager;
import me.bobert.netneutrality.core.thread.ExecutorProvider;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import reactor.core.Disposable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
/**
* Instance-based {@link MusicService} owning the shared {@link AudioPlayerManager} and the
* Instance-based {@link MusicService} owning the shared {@link LavalinkClient} and the
* per-guild {@link MusicManager} cache.
*
* <p>Audio decoding and voice sending happen on the remote Lavalink node; this class only
* registers the node and dispatches the node's event flux to the owning guild's
* {@link TrackScheduler}.
*/
public class MusicServiceImpl implements MusicService {
private static final Logger logger = LogManager.getLogger(MusicServiceImpl.class);
private static final String DEFAULT_URI = "ws://lavalink:2333";
private static final String DEFAULT_NAME = "main";
private static final long RECONNECT_WATCHDOG_SECONDS = 5;
private final SettingsProvider settings;
private final GuildServiceProvider guildServices;
private final TaskManager taskManager;
private final ExecutorProvider executors;
private final Map<String, MusicManager> managers = new ConcurrentHashMap<>();
private final AudioPlayerManager playerManager = new DefaultAudioPlayerManager();
private final List<Disposable> subscriptions = new ArrayList<>();
private final LavalinkClient client;
private ScheduledFuture<?> reconnectWatchdog;
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager) {
public MusicServiceImpl(SettingsProvider settings, GuildServiceProvider guildServices, TaskManager taskManager,
ExecutorProvider executors) {
this.settings = settings;
this.guildServices = guildServices;
this.taskManager = taskManager;
this.executors = executors;
this.client = new LavalinkClient(Helpers.getUserIdFromToken(settings.get("NETNEUTRALITY_BOT_TOKEN")));
}
@Override
@@ -50,41 +75,38 @@ public class MusicServiceImpl implements MusicService {
@Override
public void start() {
logger.debug("Initializing music player sources..");
String cipherUrl = settings.get("NETNEUTRALITY_CIPHER_URL");
String cipherPassword = settings.get("NETNEUTRALITY_CIPHER_PASSWORD");
YoutubeSourceOptions options = new YoutubeSourceOptions()
.setAllowSearch(true)
.setAllowDirectVideoIds(true)
.setAllowDirectPlaylistIds(true)
.setRemoteCipher(cipherUrl, cipherPassword, "");
playerManager.registerSourceManager(new YoutubeAudioSourceManager(options, YoutubeAudioSourceManager.DEFAULT_CLIENTS));
playerManager.registerSourceManager(SoundCloudAudioSourceManager.createDefault());
playerManager.registerSourceManager(new UdioAudioSourceManager());
playerManager.registerSourceManager(new HttpAudioSourceManager());
String po = settings.get("NETNEUTRALITY_PO_TOKEN");
String visitor = settings.get("NETNEUTRALITY_VISITOR_DATA");
dev.lavalink.youtube.clients.Web.setPoTokenAndVisitorData(po, visitor);
subscribeToEvents();
registerNode();
startReconnectWatchdog();
}
@Override
public void stop() {
for (MusicManager mm : managers.values()) {
mm.getQueueManager().clearQueue();
mm.getPlayer().destroy();
if (reconnectWatchdog != null) {
reconnectWatchdog.cancel(false);
reconnectWatchdog = null;
}
for (Disposable subscription : subscriptions) {
subscription.dispose();
}
subscriptions.clear();
for (MusicManager manager : managers.values()) {
manager.getQueueManager().clearQueue();
manager.destroy();
}
managers.clear();
client.close();
}
@Override
public MusicManager get(Guild guild) {
if (managers.containsKey(guild.getId())) {
return managers.get(guild.getId());
}
MusicManager manager = new MusicManager(guild, playerManager, guildServices.get(guild), taskManager);
managers.put(guild.getId(), manager);
return manager;
return managers.computeIfAbsent(
guild.getId(),
id -> new MusicManager(guild, client, guildServices.get(guild), taskManager)
);
}
@Override
@@ -93,11 +115,106 @@ public class MusicServiceImpl implements MusicService {
}
/**
* Returns the shared Lavaplayer {@link AudioPlayerManager}.
* Returns the shared Lavalink client.
*
* @return the player manager
* @return the client
*/
public AudioPlayerManager getPlayerManager() {
return playerManager;
@Override
public LavalinkClient getClient() {
return client;
}
/**
* Registers the configured node. A node that is unreachable at startup is retried by the
* client itself, so this never prevents the bot from booting.
*/
private void registerNode() {
String uri = settings.get("NETNEUTRALITY_LAVALINK_URI", DEFAULT_URI);
String password = settings.get("NETNEUTRALITY_LAVALINK_PASSWORD");
String name = settings.get("NETNEUTRALITY_LAVALINK_NAME", DEFAULT_NAME);
if (password == null) {
logger.error("No Lavalink password configured (NETNEUTRALITY_LAVALINK_PASSWORD), music will be unavailable");
return;
}
try {
client.addNode(new NodeOptions.Builder()
.setName(name)
.setServerUri(uri)
.setPassword(password)
.build());
logger.info("Registered Lavalink node '{}' at {}", name, uri);
} catch (RuntimeException e) {
logger.error("Failed to register Lavalink node '{}' at {}, music will be unavailable", name, uri, e);
}
}
/**
* Re-arms the client's own reconnect loop for nodes whose socket was closed.
*
* <p>The Lavalink client disables reconnection permanently when the node closes the socket
* with a normal close code (1000/1001), which is exactly what a graceful node restart does.
* Without this watchdog the bot would stay disconnected until it is restarted itself.
*/
private void startReconnectWatchdog() {
reconnectWatchdog = executors.getScheduledExecutor().scheduleWithFixedDelay(
this::rearmReconnects,
RECONNECT_WATCHDOG_SECONDS,
RECONNECT_WATCHDOG_SECONDS,
TimeUnit.SECONDS
);
}
private void rearmReconnects() {
try {
for (LavalinkNode node : client.getNodes()) {
LavalinkSocket socket = node.getWs();
if (!socket.getOpen() && !socket.getMayReconnect()) {
logger.warn("Lavalink node '{}' closed the connection normally, re-arming reconnects", node.getName());
socket.setMayReconnect(true);
}
}
} catch (RuntimeException e) {
logger.error("Failed to re-arm Lavalink reconnects", e);
}
}
/**
* Opens the single subscription to the client event flux and routes every guild-scoped event
* to the owning guild's {@link TrackScheduler}.
*/
private void subscribeToEvents() {
subscriptions.add(client.on(ReadyEvent.class)
.subscribe(event -> logger.info("Lavalink node '{}' is ready (session {})", event.getNode().getName(), event.getSessionId())));
subscriptions.add(client.on(TrackStartEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStart(event.getTrack()))));
subscriptions.add(client.on(TrackEndEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackEnd(event.getTrack(), event.getEndReason()))));
subscriptions.add(client.on(TrackExceptionEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackException(event.getTrack(), event.getException()))));
subscriptions.add(client.on(TrackStuckEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onTrackStuck(event.getTrack(), event.getThresholdMs()))));
subscriptions.add(client.on(WebSocketClosedEvent.class)
.subscribe(event -> dispatch(event.getGuildId(), scheduler -> scheduler.onWebSocketClosed(event.getCode(), event.getReason(), event.getByRemote()))));
}
private void dispatch(long guildId, Consumer<TrackScheduler> action) {
MusicManager manager = managers.get(String.valueOf(guildId));
if (manager == null) {
logger.debug("Received a Lavalink event for unknown guild {}", guildId);
return;
}
try {
action.accept(manager.getScheduler());
} catch (RuntimeException e) {
logger.error("Guild {}: failed to handle Lavalink event", guildId, e);
}
}
}
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@@ -23,13 +22,11 @@ import java.util.function.Predicate;
public class QueueManager {
private final Logger logger = LogManager.getLogger(QueueManager.class);
private final TrackScheduler scheduler;
private final AudioPlayer player;
private final Guild guild;
private final Deque<TrackInfo> queue;
public QueueManager(TrackScheduler scheduler) {
this.scheduler = scheduler;
this.player = scheduler.getPlayer();
this.guild = scheduler.getGuild();
this.queue = new LinkedBlockingDeque<>();
@@ -110,20 +107,20 @@ public class QueueManager {
}
}
public List<AudioTrack> getAllQueuedTracks() {
public List<Track> getAllQueuedTracks() {
synchronized (queue) {
ArrayList<AudioTrack> tracks = new ArrayList<>();
ArrayList<Track> tracks = new ArrayList<>();
queue.forEach(trackInfo -> tracks.add(trackInfo.getTrack()));
return tracks;
}
}
public void queue(List<AudioTrack> tracks, Member member) {
public void queue(List<Track> tracks, Member member) {
tracks.forEach(track -> queue(track, member));
}
public void queue(AudioTrack track, Member member) {
public void queue(Track track, Member member) {
TrackInfo info = new TrackInfo(track, member);
synchronized (queue) {
@@ -131,42 +128,67 @@ public class QueueManager {
return;
}
if (player.getPlayingTrack() == null && !queue.isEmpty()) {
boolean idle = scheduler.getCurrentTrack() == null;
if (idle && !queue.isEmpty()) {
queue.addFirst(info);
} else {
queue.add(info);
}
if (player.getPlayingTrack() == null) {
AudioTrack toPlay = queue.peek() == null ? null : queue.peek().getTrack();
player.startTrack(toPlay, true);
if (idle) {
TrackInfo head = queue.peek();
if (head != null) {
startTrack(head.getTrack(), true);
}
}
}
}
/**
* Sends a play request to the node. The cached current track is updated eagerly so that a
* burst of enqueues does not start several tracks at once while waiting for the node's
* {@code TrackStartEvent}.
*
* @param track the track to play
* @param noReplace whether an already playing track must not be interrupted
*/
private void startTrack(Track track, boolean noReplace) {
scheduler.setCurrentTrack(track);
scheduler.getMusicManager().updatePlayer(
"start track " + track.getInfo().getIdentifier(),
builder -> builder.setTrack(track).setNoReplace(noReplace)
);
}
private void stopTrack() {
scheduler.setCurrentTrack(null);
scheduler.getMusicManager().stopTrack();
}
public CommandExecutionResult resume() {
synchronized (queue) {
TrackInfo next = queue.peek();
if (next != null) {
player.startTrack(next.getTrack().makeClone(), false);
return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().title);
startTrack(next.getTrack().makeClone(), false);
return CommandExecutionResult.success("Starting playback of track " + next.getTrack().getInfo().getTitle());
}
player.stopTrack();
stopTrack();
return CommandExecutionResult.info("Queue is empty, leaving voice channel");
}
}
public void startNextTrack() {
synchronized (queue) {
TrackInfo last = queue.poll();
queue.poll();
TrackInfo next = queue.peek();
if (next != null) {
player.startTrack(next.getTrack().makeClone(), false);
startTrack(next.getTrack().makeClone(), false);
} else {
player.stopTrack();
stopTrack();
}
}
}
@@ -68,8 +68,10 @@ public class SourceFinder {
String identifier;
identifier = switch (getSource(url)) {
case Youtube, YTMusic, Soundcloud, Udio, DirectLinkMp3 -> url;
case Youtube, YTMusic, Soundcloud, DirectLinkMp3 -> url;
case YTSearch -> "ytsearch:" + 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;
};
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context;
import org.apache.logging.log4j.LogManager;
@@ -14,19 +13,19 @@ import org.apache.logging.log4j.Logger;
/**
* Encapsulates the retry-or-skip logic for stuck tracks, extracted from
* {@link TrackScheduler#onTrackStuck(AudioPlayer, AudioTrack, long)}.
* {@link TrackScheduler#onTrackStuck(Track, long)}.
*/
class StuckTrackRecoveryHandler {
private static final Logger logger = LogManager.getLogger(StuckTrackRecoveryHandler.class);
private final AudioPlayer player;
private final MusicManager manager;
private final QueueManager queueManager;
private AudioTrack lastStuckTrack;
private Track lastStuckTrack;
StuckTrackRecoveryHandler(AudioPlayer player, QueueManager queueManager) {
this.player = player;
StuckTrackRecoveryHandler(MusicManager manager, QueueManager queueManager) {
this.manager = manager;
this.queueManager = queueManager;
}
@@ -40,11 +39,11 @@ class StuckTrackRecoveryHandler {
/**
* Handles a stuck-track event. On the first occurrence for a given track
* identifier the handler attempts to clone and re-queue the track; on the
* identifier the handler attempts to re-queue the track; on the
* second consecutive occurrence it skips to the next track.
*/
void handle(AudioTrack track, Context context) {
if (lastStuckTrack == null || !lastStuckTrack.getIdentifier().equals(track.getIdentifier())) {
void handle(Track track, Context context) {
if (lastStuckTrack == null || !identifierOf(lastStuckTrack).equals(identifierOf(track))) {
lastStuckTrack = track;
attemptRecovery(track, context);
} else {
@@ -53,28 +52,26 @@ class StuckTrackRecoveryHandler {
}
}
private void attemptRecovery(AudioTrack track, Context context) {
logger.debug("Trying to load stuck track again: {}", track.getIdentifier());
private void attemptRecovery(Track track, Context context) {
logger.debug("Trying to load stuck track again: {}", identifierOf(track));
context.async("Current track is stuck, trying to load it again", asyncContext -> {
try {
TrackInfo info = queueManager.peekHeadOfQueue();
if (info != null && info.getTrack().getIdentifier().equals(track.getIdentifier())) {
AudioTrack clone = track.makeClone();
long lastPosition = track.getPosition();
if (info != null && identifierOf(info.getTrack()).equals(identifierOf(track))) {
long lastPosition = manager.getCurrentPosition();
player.stopTrack();
manager.stopTrack();
if (queueManager.peekHeadOfQueue() != null
&& queueManager.peekHeadOfQueue().getTrack().getIdentifier().equals(track.getIdentifier())) {
TrackInfo head = queueManager.peekHeadOfQueue();
if (head != null && identifierOf(head.getTrack()).equals(identifierOf(track))) {
queueManager.removeHeadOfQueue();
}
queueManager.queue(clone, info.getRequester());
queueManager.queue(track.makeClone(), info.getRequester());
AudioTrack playing = player.getPlayingTrack();
if (playing != null && playing.getIdentifier().equals(track.getIdentifier())) {
playing.setPosition(lastPosition);
if (lastPosition > 0) {
manager.updatePlayer("seek to " + lastPosition, builder -> builder.setPosition(lastPosition));
lastStuckTrack = null;
} else {
asyncContext.edit("Failed to seek track to last timestamp, the track will play from the beginning");
@@ -90,4 +87,8 @@ class StuckTrackRecoveryHandler {
}
});
}
private static String identifierOf(Track track) {
return track.getInfo().getIdentifier();
}
}
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
@@ -16,18 +16,18 @@ import java.util.Objects;
import java.util.Set;
public class TrackInfo {
private final AudioTrack track;
private final Track track;
private final Member member;
private final Set<String> skips;
private static final Logger logger = LogManager.getLogger(TrackInfo.class);
public TrackInfo(AudioTrack track, Member member) {
public TrackInfo(Track track, Member member) {
this.track = track;
this.member = member;
this.skips = new HashSet<>();
}
public AudioTrack getTrack() {
public Track getTrack() {
return track;
}
@@ -54,6 +54,11 @@ public class TrackInfo {
return member;
}
/**
* Two entries are equal when they wrap the same encoded track, which is what makes the queue
* reject duplicates. The Lavalink {@link Track} type has no value equality, so the encoded
* payload is compared instead.
*/
@Override
public boolean equals(Object o) {
if (this == o) {
@@ -63,18 +68,18 @@ public class TrackInfo {
return false;
}
TrackInfo trackInfo = (TrackInfo) o;
return track.equals(trackInfo.track);
return track.getEncoded().equals(trackInfo.track.getEncoded());
}
@Override
public int hashCode() {
return Objects.hash(track);
return Objects.hash(track.getEncoded());
}
@Override
public String toString() {
return "TrackInfo{" +
"track=" + track.getInfo().title +
"track=" + track.getInfo().getTitle() +
", member=" + member.getEffectiveName() +
", skips=" + skips +
'}';
@@ -6,20 +6,27 @@ package me.bobert.netneutrality.music;
*/
import me.bobert.netneutrality.utils.MusicUtils;
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 dev.arbjerg.lavalink.client.AbstractAudioLoadResultHandler;
import dev.arbjerg.lavalink.client.player.LoadFailed;
import dev.arbjerg.lavalink.client.player.PlaylistLoaded;
import dev.arbjerg.lavalink.client.player.SearchResult;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackLoaded;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.tasks.music.LoadPlaylistTask;
import me.bobert.netneutrality.shared.util.MessageFunction;
import me.bobert.netneutrality.tasks.music.LoadPlaylistTask;
import me.bobert.netneutrality.utils.MusicUtils;
import net.dv8tion.jda.api.entities.Guild;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class TrackLoadingHandler implements AudioLoadResultHandler {
import java.util.List;
/**
* Translates a Lavalink load result into queue operations and user feedback.
*/
public class TrackLoadingHandler extends AbstractAudioLoadResultHandler {
private static final Logger logger = LogManager.getLogger(TrackLoadingHandler.class);
private final String identifier;
@@ -42,24 +49,42 @@ public class TrackLoadingHandler implements AudioLoadResultHandler {
}
@Override
public void trackLoaded(AudioTrack audioTrack) {
logger.debug("Guild {}: track loaded: {}", guild.getId(), audioTrack.getIdentifier());
public void ontrackLoaded(TrackLoaded result) {
Track track = result.getTrack();
logger.debug("Guild {}: track loaded: {}", guild.getId(), track.getInfo().getIdentifier());
context.deleteMessage();
if (MusicUtils.connect(context, manager)) {
queueManager.queue(audioTrack, context.getGuildUser());
queueManager.queue(track, context.getGuildUser());
if (!silent) {
context.reply(MessageFunction.buildAddingTrackMessage(audioTrack, context.getGuildUser(), type));
context.reply(MessageFunction.buildAddingTrackMessage(track, context.getGuildUser(), type));
}
}
}
@Override
public void playlistLoaded(AudioPlaylist playlist) {
public void onPlaylistLoaded(PlaylistLoaded result) {
logger.debug("Guild {}: playlist loaded: {}", guild.getId(), identifier);
new LoadPlaylistTask(playlist, type, manager).insert(context);
new LoadPlaylistTask(result.getInfo().getName(), result.getTracks(), false, type, manager).insert(context);
}
/**
* Search results keep the pre-migration behaviour: the user is offered the first few hits
* through the same playlist task that lavaplayer's search playlists used to produce.
*/
@Override
public void onSearchResultLoaded(SearchResult result) {
List<Track> tracks = result.getTracks();
logger.debug("Guild {}: search returned {} results for {}", guild.getId(), tracks.size(), identifier);
if (tracks.isEmpty()) {
noMatches();
return;
}
new LoadPlaylistTask("Search results", tracks, true, type, manager).insert(context);
}
@Override
@@ -72,11 +97,11 @@ public class TrackLoadingHandler implements AudioLoadResultHandler {
}
@Override
public void loadFailed(FriendlyException e) {
public void loadFailed(LoadFailed result) {
logger.debug("Guild {}: load failed for identifier {}", guild.getId(), identifier);
//TODO: better error messages
RuntimeException exception = new RuntimeException("Failed to load track: " + e.getMessage(), e);
RuntimeException exception = new RuntimeException("Failed to load track: " + result.getException().getMessage());
LogHandler.log(exception);
if (!silent) {
@@ -5,11 +5,9 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.player.event.AudioEventAdapter;
import com.sedmelluq.discord.lavaplayer.tools.FriendlyException;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.client.player.TrackException;
import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
import me.bobert.netneutrality.context.MusicChannelContext;
import me.bobert.netneutrality.core.LogHandler;
import me.bobert.netneutrality.core.context.Context;
@@ -19,23 +17,28 @@ import net.dv8tion.jda.api.entities.Member;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class TrackScheduler extends AudioEventAdapter {
/**
* Sink for the track lifecycle events emitted by the Lavalink node.
*
* <p>Events are delivered by the central dispatcher in {@link MusicServiceImpl}, which routes
* them to the scheduler of the guild they belong to.
*/
public class TrackScheduler {
private static final Logger logger = LogManager.getLogger(TrackScheduler.class);
private final QueueManager queueManager;
private final MusicManager manager;
private final MusicManagerCallback callback;
private final AudioPlayer player;
private final Guild guild;
private volatile Context context;
private volatile Track currentTrack;
private final StuckTrackRecoveryHandler stuckTrackRecoveryHandler;
public TrackScheduler(MusicManager manager) {
this.manager = manager;
this.callback = manager;
this.player = manager.getPlayer();
this.guild = manager.getGuild();
this.queueManager = new QueueManager(this);
this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(player, queueManager);
this.stuckTrackRecoveryHandler = new StuckTrackRecoveryHandler(manager, queueManager);
}
public QueueManager getQueueManager() {
@@ -50,14 +53,26 @@ public class TrackScheduler extends AudioEventAdapter {
return callback;
}
public AudioPlayer getPlayer() {
return player;
}
public Guild getGuild() {
return guild;
}
/**
* Returns the track the node is currently playing.
*
* <p>The Lavalink player state is only readable through a REST call, so the last known track
* is cached here and kept up to date by the node's events.
*
* @return the current track or {@code null}
*/
public Track getCurrentTrack() {
return currentTrack;
}
void setCurrentTrack(Track track) {
this.currentTrack = track;
}
public Member getDj() {
Context ctx = context;
if (ctx != null) {
@@ -83,36 +98,30 @@ public class TrackScheduler extends AudioEventAdapter {
return stuckTrackRecoveryHandler;
}
@Override
public void onPlayerPause(AudioPlayer player) {
// Player was paused
}
@Override
public void onPlayerResume(AudioPlayer player) {
// Player was resumed
}
@Override
public void onTrackStart(AudioPlayer player, AudioTrack track) {
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getIdentifier());
public void onTrackStart(Track track) {
logger.debug("Guild {}: starting track: {}", guild.getId(), track.getInfo().getIdentifier());
currentTrack = track;
stuckTrackRecoveryHandler.reset();
}
@Override
public void onTrackEnd(AudioPlayer player, AudioTrack track, AudioTrackEndReason endReason) {
logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getIdentifier());
public void onTrackEnd(Track track, TrackEndEvent.AudioTrackEndReason endReason) {
logger.debug("Guild {}: currently playing track ended: {}", guild.getId(), track.getInfo().getIdentifier());
if (endReason == AudioTrackEndReason.LOAD_FAILED) {
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getIdentifier());
if (endReason != TrackEndEvent.AudioTrackEndReason.REPLACED) {
currentTrack = null;
}
if (endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
logger.debug("Guild {}: failed loading track: {}", guild.getId(), track.getInfo().getIdentifier());
}
// check if mayStartNext
if (endReason == AudioTrackEndReason.FINISHED || endReason == AudioTrackEndReason.LOAD_FAILED) {
if (endReason == TrackEndEvent.AudioTrackEndReason.FINISHED
|| endReason == TrackEndEvent.AudioTrackEndReason.LOAD_FAILED) {
queueManager.startNextTrack();
}
if (endReason == AudioTrackEndReason.STOPPED) {
if (endReason == TrackEndEvent.AudioTrackEndReason.STOPPED) {
// maybe skip removing head to make resuming easier?
queueManager.removeHeadOfQueue();
}
@@ -125,16 +134,23 @@ public class TrackScheduler extends AudioEventAdapter {
// clone of this back to your queue
}
@Override
public void onTrackException(AudioPlayer player, AudioTrack track, FriendlyException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getIdentifier());
public void onTrackException(Track track, TrackException exception) {
logger.error("Guild {}: exception while playing track: {}", guild.getId(), track.getInfo().getIdentifier());
LogHandler.log(new ContextException(String.format("Error while playing track: %s", track.getInfo().title), getContext(), exception));
LogHandler.log(new ContextException(
String.format("Error while playing track: %s", track.getInfo().getTitle()),
getContext(),
new RuntimeException(exception.getMessage())
));
}
@Override
public void onTrackStuck(AudioPlayer player, AudioTrack track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {}", guild.getId(), track.getIdentifier());
public void onTrackStuck(Track track, long thresholdMs) {
logger.error("Guild {}: currently playing track is stuck: {} (threshold {}ms)", guild.getId(), track.getInfo().getIdentifier(), thresholdMs);
stuckTrackRecoveryHandler.handle(track, context);
}
public void onWebSocketClosed(int code, String reason, boolean byRemote) {
logger.warn("Guild {}: voice websocket closed with code {} ({}), by remote: {}", guild.getId(), code, reason, byRemote);
currentTrack = null;
}
}
@@ -50,7 +50,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
}
private void processManager(MusicManager manager) throws InterruptedException {
AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel();
AudioChannel channel = manager.getConnectedChannel();
if (channel != null) {
logger.debug("Processing MusicPlayerAutoDisconnectThread for guild {}", manager.getGuild().getId());
@@ -68,7 +68,7 @@ public class MusicPlayerAutoDisconnect implements ScheduledTask {
}
private boolean conditionsMetToDisconnect(MusicManager manager) {
AudioChannel channel = manager.getGuild().getAudioManager().getConnectedChannel();
AudioChannel channel = manager.getConnectedChannel();
if (channel != null) {
List<Member> members = channel.getMembers().stream().filter(member -> !member.getId().equals(channel.getJDA().getSelfUser().getId())).toList();
@@ -6,9 +6,8 @@ package me.bobert.netneutrality.shared.util;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.protocol.v4.TrackInfo;
import io.sentry.Sentry;
import me.bobert.netneutrality.core.command.Command;
import me.bobert.netneutrality.core.command.CommandCategory;
@@ -20,7 +19,6 @@ import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.exceptions.ContextException;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.music.TrackInfo;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Member;
import net.dv8tion.jda.api.entities.Message;
@@ -40,32 +38,30 @@ public class MessageFunction {
private static final Logger logger = LogManager.getLogger(MessageFunction.class);
public static MessageEmbed buildCurrentlyPlayingMessage(MusicManager manager) {
AudioTrack current = manager.getCurrentlyPlaying();
Track current = manager.getCurrentlyPlaying();
TrackInfo currentInfo = current.getInfo();
EmbedBuilder message = new EmbedBuilder();
message.setAuthor("Link to track (click me)", String.format("https://youtu.be/%s", current.getIdentifier()));
message.setThumbnail(String.format("https://img.youtube.com/vi/%s/default.jpg", current.getIdentifier()));
message.setAuthor("Link to track (click me)", String.format("https://youtu.be/%s", currentInfo.getIdentifier()));
message.setThumbnail(String.format("https://img.youtube.com/vi/%s/default.jpg", currentInfo.getIdentifier()));
//message.setTitle("Currently playing:");
message.setColor(Color.BLUE);
message.addField("Currently playing: ", current.getInfo().title, true);
message.addField("Duration: ", DurationFormatUtils.formatDuration(current.getPosition(), "HH:mm:ss", true) + " / " + DurationFormatUtils.formatDuration(current.getDuration(), "HH:mm:ss", true), true);
message.addField("Currently playing: ", currentInfo.getTitle(), true);
message.addField("Duration: ", DurationFormatUtils.formatDuration(manager.getCurrentPosition(), "HH:mm:ss", true) + " / " + DurationFormatUtils.formatDuration(currentInfo.getLength(), "HH:mm:ss", true), true);
message.setTimestamp(OffsetDateTime.now());
return message.build();
}
public static MessageEmbed buildAddingTrackMessage(AudioTrack track, Member member, SourceFinder.AudioSourceType type) {
public static MessageEmbed buildAddingTrackMessage(Track track, Member member, SourceFinder.AudioSourceType type) {
EmbedBuilder message = new EmbedBuilder();
String url = track.getIdentifier();
String thumbnail = null;
TrackInfo info = track.getInfo();
String url = info.getIdentifier();
String thumbnail = info.getArtworkUrl();
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch || type == SourceFinder.AudioSourceType.YTMusic) {
url = String.format("https://youtu.be/%s", track.getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", track.getIdentifier());
}
if (type == SourceFinder.AudioSourceType.Udio) {
thumbnail = track.getInfo().artworkUrl;
url = String.format("https://youtu.be/%s", info.getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", info.getIdentifier());
}
if (thumbnail != null) {
@@ -73,26 +69,26 @@ public class MessageFunction {
}
message.setTitle("Adding track to queue..");
message.setColor(Color.BLUE);
message.addField("Track title: ", String.format("[**`%s`**](%s)", track.getInfo().title, url), false);
message.addField("Duration: ", DurationFormatUtils.formatDuration(track.getDuration(), "**HH:mm:ss**", true), false);
message.addField("Track title: ", String.format("[**`%s`**](%s)", info.getTitle(), url), false);
message.addField("Duration: ", DurationFormatUtils.formatDuration(info.getLength(), "**HH:mm:ss**", true), false);
message.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
message.setTimestamp(OffsetDateTime.now());
return message.build();
}
public static MessageEmbed buildAddingPlaylistMessage(List<AudioTrack> tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) {
public static MessageEmbed buildAddingPlaylistMessage(List<Track> tracks, String playlistName, Member member, SourceFinder.AudioSourceType type) {
EmbedBuilder message = new EmbedBuilder();
String url = "";
String thumbnail = null;
if (type == SourceFinder.AudioSourceType.Youtube || type == SourceFinder.AudioSourceType.YTSearch) {
url = String.format("https://youtu.be/%s", tracks.get(0).getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getIdentifier());
url = String.format("https://youtu.be/%s", 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) {
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getIdentifier());
thumbnail = String.format("https://i3.ytimg.com/vi/%s/hqdefault.jpg", tracks.get(0).getInfo().getIdentifier());
}
if (thumbnail != null) {
@@ -109,13 +105,13 @@ public class MessageFunction {
}
public static String buildViewQueueMessage(MusicManager manager) {
List<AudioTrack> tracks = manager.getQueueManager().getAllQueuedTracks();
List<Track> tracks = manager.getQueueManager().getAllQueuedTracks();
if (tracks.isEmpty()) {
return "Queue is empty!";
}
int limit = 10;
AudioTrack current = manager.getCurrentlyPlaying();
Track current = manager.getCurrentlyPlaying();
int numberofTracksShown = Math.min(tracks.size(), limit);
StringBuilder message = new StringBuilder();
@@ -127,15 +123,15 @@ public class MessageFunction {
.append(" items):\n");
int index = 0;
for (AudioTrack track : tracks) {
for (Track track : tracks) {
index++;
AudioTrackInfo info = track.getInfo();
TrackInfo info = track.getInfo();
message.append("\n");
if (current != null && current.getInfo().title.equals(info.title)) {
message.append("- ").append("**").append(info.title).append("**");
if (current != null && current.getInfo().getTitle().equals(info.getTitle())) {
message.append("- ").append("**").append(info.getTitle()).append("**");
} else {
message.append("- ").append(info.title);
message.append("- ").append(info.getTitle());
}
if (index == limit) {
@@ -147,8 +143,8 @@ public class MessageFunction {
}
public static MessageEmbed buildUserTracksMessage(MusicManager manager, Member member) {
List<TrackInfo> tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester()));
AudioTrack current = manager.getCurrentlyPlaying();
List<me.bobert.netneutrality.music.TrackInfo> tracks = manager.getQueueManager().getQueueWithFilter(trackInfo -> member.equals(trackInfo.getRequester()));
Track current = manager.getCurrentlyPlaying();
int numberofTracksShown = Math.min(tracks.size(), 10);
EmbedBuilder message = new EmbedBuilder();
@@ -156,13 +152,13 @@ public class MessageFunction {
message.setColor(Color.BLUE);
int index = 0;
for (TrackInfo track : tracks) {
AudioTrackInfo info = track.getTrack().getInfo();
for (me.bobert.netneutrality.music.TrackInfo track : tracks) {
TrackInfo info = track.getTrack().getInfo();
if (current != null && current.getInfo().title.equals(info.title)) {
message.addField("Track #" + ++index + " (Currently playing)", info.title, false);
if (current != null && current.getInfo().getTitle().equals(info.getTitle())) {
message.addField("Track #" + ++index + " (Currently playing)", info.getTitle(), false);
} else {
message.addField("Track #" + ++index, info.title, false);
message.addField("Track #" + ++index, info.getTitle(), false);
}
if (index == 10) {
@@ -174,7 +170,7 @@ public class MessageFunction {
return message.build();
}
public static MessageEmbed buildSearchResultsMessage(List<AudioTrack> tracks, Member member) {
public static MessageEmbed buildSearchResultsMessage(List<Track> tracks, Member member) {
EmbedBuilder embed = new EmbedBuilder();
embed.setTitle("Search results");
@@ -182,8 +178,8 @@ public class MessageFunction {
embed.setColor(Color.MAGENTA);
int i = 0;
for (AudioTrack track : tracks) {
embed.addField(++i + ": ", track.getInfo().title, false);
for (Track track : tracks) {
embed.addField(++i + ": ", track.getInfo().getTitle(), false);
}
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
@@ -192,11 +188,11 @@ public class MessageFunction {
return embed.build();
}
public static MessageEmbed buildPlaylistLoadMessage(AudioPlaylist playlist, Member member) {
public static MessageEmbed buildPlaylistLoadMessage(String playlistName, int trackCount, Member member) {
EmbedBuilder embed = new EmbedBuilder();
embed.setTitle(String.format("Loading playlist: %s", playlist.getName()));
embed.setDescription(String.format("You are about to load %d tracks from this playlist, are you sure?", playlist.getTracks().size()));
embed.setTitle(String.format("Loading playlist: %s", playlistName));
embed.setDescription(String.format("You are about to load %d tracks from this playlist, are you sure?", trackCount));
embed.setColor(Color.CYAN);
embed.setFooter(member.getEffectiveName(), member.getEffectiveAvatarUrl());
@@ -5,8 +5,7 @@ package me.bobert.netneutrality.tasks.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioPlaylist;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.tasks.Task;
import me.bobert.netneutrality.core.tasks.impl.ButtonTask;
@@ -28,13 +27,18 @@ import java.util.List;
public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deletable {
private final List<Task> childrens = new ArrayList<>();
private final AudioPlaylist playlist;
private final String playlistName;
private final List<Track> playlistTracks;
private final boolean searchResult;
private final SourceFinder.AudioSourceType sourceType;
private final MusicManager musicManager;
public LoadPlaylistTask(AudioPlaylist playlist, SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
public LoadPlaylistTask(String playlistName, List<Track> playlistTracks, boolean searchResult,
SourceFinder.AudioSourceType sourceType, MusicManager musicManager) {
super(musicManager.getTaskManager(), null, null);
this.playlist = playlist;
this.playlistName = playlistName;
this.playlistTracks = playlistTracks;
this.searchResult = searchResult;
this.sourceType = sourceType;
this.musicManager = musicManager;
}
@@ -51,7 +55,7 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
@Override
public void replyToInteraction(CommandContext context) {
if (playlist.getTracks().isEmpty()) {
if (playlistTracks.isEmpty()) {
context.reply("No tracks found");
return;
}
@@ -62,12 +66,12 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
.getSetting(GSettings.MUSIC_MAXPLAYLISTSIZE));
// Search results
if (playlist.isSearchResult()) {
List<AudioTrack> searchResults = playlist.getTracks().stream().limit(searchResultsLimit).toList();
if (searchResult) {
List<Track> searchResults = playlistTracks.stream().limit(searchResultsLimit).toList();
List<Task> loadTasks = new ArrayList<>();
for (int i = 0; i < searchResults.size(); i++) {
AudioTrack track = searchResults.get(i);
Track track = searchResults.get(i);
loadTasks.add(new LoadTrackFromPlaylistTask(this, String.valueOf(i + 1), track, musicManager));
}
@@ -79,11 +83,11 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
return;
}
List<AudioTrack> tracks = playlist.getTracks();
List<Track> tracks = playlistTracks;
if (tracks.size() > maxPlaylistSizeBeforeAsking) {
ActionRow firstRow = commonButtons(playlist.getTracks(), "I'm ready", "Please don't");
ActionRow firstRow = commonButtons(tracks, "I'm ready", "Please don't");
context.edit(MessageFunction.buildPlaylistLoadMessage(playlist, context.getGuildUser()), List.of(firstRow));
context.edit(MessageFunction.buildPlaylistLoadMessage(playlistName, tracks.size(), context.getGuildUser()), List.of(firstRow));
return;
}
@@ -91,17 +95,17 @@ public class LoadPlaylistTask extends ButtonTask implements WithChildren, Deleta
if (MusicUtils.connect(context, musicManager)) {
musicManager.getQueueManager().queue(tracks, context.getGuildUser());
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), context.getGuildUser(), sourceType));
context.edit(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, context.getGuildUser(), sourceType));
}
}
private ActionRow commonButtons(List<AudioTrack> tracks, String addAllText, String cancelText) {
private ActionRow commonButtons(List<Track> tracks, String addAllText, String cancelText) {
Task accept = AcceptButton.createSuccessButton(taskManager(), this, addAllText, context -> {
Member member = context.getGuildUser();
if (MusicUtils.connect(context, musicManager)) {
musicManager.getQueueManager().queue(tracks, member);
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlist.getName(), member, SourceFinder.AudioSourceType.Youtube));
context.reply(MessageFunction.buildAddingPlaylistMessage(tracks, playlistName, member, SourceFinder.AudioSourceType.Youtube));
return Lifecycle.REMOVE_SELF_AND_CHILDREN;
}
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.tasks.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.music.MusicManager;
import me.bobert.netneutrality.music.SourceFinder;
import me.bobert.netneutrality.core.tasks.generic.AcceptButton;
@@ -15,9 +15,9 @@ import net.dv8tion.jda.api.components.buttons.ButtonStyle;
import net.dv8tion.jda.api.entities.Member;
public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
private final AudioTrack track;
private final Track track;
public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, AudioTrack track, MusicManager musicManager) {
public LoadTrackFromPlaylistTask(LoadPlaylistTask parentTask, String buttonLabel, Track track, MusicManager musicManager) {
super(musicManager.getTaskManager(), parentTask, ButtonStyle.PRIMARY, buttonLabel, context -> {
Member member = context.getGuildUser();
@@ -36,6 +36,6 @@ public class LoadTrackFromPlaylistTask extends AcceptButton<LoadPlaylistTask> {
@Override
public String getName() {
return String.format("load-track-%s", track.getIdentifier());
return String.format("load-track-%s", track.getInfo().getIdentifier());
}
}
@@ -0,0 +1,498 @@
package me.bobert.netneutrality.core.tasks;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.bootstrap.ApplicationContext;
import me.bobert.netneutrality.context.tasks.ButtonTaskContext;
import me.bobert.netneutrality.core.context.CommandContext;
import me.bobert.netneutrality.core.context.InteractionContext;
import me.bobert.netneutrality.core.context.TaskContext;
import me.bobert.netneutrality.core.tasks.impl.AbstractTask;
import me.bobert.netneutrality.core.tasks.trait.Deletable;
import me.bobert.netneutrality.core.tasks.trait.WithButton;
import me.bobert.netneutrality.core.tasks.trait.WithChildren;
import me.bobert.netneutrality.core.tasks.trait.WithParent;
import me.bobert.netneutrality.db.service.TaskService;
import me.bobert.netneutrality.scheduled.cleanup.TaskCleanup;
import net.dv8tion.jda.api.components.buttons.Button;
import net.dv8tion.jda.api.interactions.InteractionHook;
import net.dv8tion.jda.api.requests.RestAction;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.Field;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
@ExtendWith(MockitoExtension.class)
class TaskManagerImplTest {
@Mock
private ApplicationContext applicationContext;
@Mock
private CommandContext commandContext;
@Mock
private ButtonTaskContext buttonTaskContext;
@Mock
private TaskService taskService;
private TaskManagerImpl taskManager;
@BeforeEach
void setUp() throws Exception {
taskManager = new TaskManagerImpl(applicationContext);
Field taskServiceField = TaskManagerImpl.class.getDeclaredField("taskService");
taskServiceField.setAccessible(true);
taskServiceField.set(taskManager, taskService);
}
private static class TestTask extends AbstractTask {
private final String id;
private final String lockId;
private boolean expired;
private Lifecycle lifecycleToReturn = Lifecycle.REMOVE_SELF;
private Consumer<TaskContext> onExecute;
TestTask(TaskManager taskManager, String id) {
this(taskManager, id, id);
}
TestTask(TaskManager taskManager, String id, String lockId) {
super(taskManager);
this.id = id;
this.lockId = lockId;
}
@Override
public String getId() {
return id;
}
@Override
public String getName() {
return "TestTask-" + id;
}
@Override
public String getLockId() {
return lockId;
}
@Override
public boolean isInsertable() {
return true;
}
@Override
public boolean isExpired() {
return expired;
}
public void setExpired(boolean expired) {
this.expired = expired;
}
public void setLifecycleToReturn(Lifecycle lifecycleToReturn) {
this.lifecycleToReturn = lifecycleToReturn;
}
public void setOnExecute(Consumer<TaskContext> onExecute) {
this.onExecute = onExecute;
}
@Override
public Lifecycle execute(TaskContext context) {
if (onExecute != null) {
onExecute.accept(context);
}
return lifecycleToReturn;
}
@Override
public void replyToInteraction(CommandContext context) {
}
}
private static class TestParentTask extends TestTask implements WithChildren {
private final List<Task> childTasks;
TestParentTask(TaskManager taskManager, String id, List<Task> childTasks) {
super(taskManager, id);
this.childTasks = childTasks;
}
@Override
public List<Task> getChildTasks() {
return childTasks;
}
}
private static class TestChildTask extends TestTask implements WithParent<TestParentTask> {
private final TestParentTask parent;
TestChildTask(TaskManager taskManager, String id, TestParentTask parent) {
super(taskManager, id, parent != null ? parent.getLockId() : id);
this.parent = parent;
}
@Override
public TestParentTask getParentTask() {
return parent;
}
}
private static class TestButtonTask extends TestTask implements WithButton {
TestButtonTask(TaskManager taskManager, String id) {
super(taskManager, id);
}
@Override
public Button createButton() {
return Button.primary(getId(), getName());
}
}
private static class TestStepTask extends TestTask implements StepTask {
TestStepTask(TaskManager taskManager, String id) {
super(taskManager, id);
}
@Override
public StepType getStepType() {
return StepType.STRING;
}
@Override
public String getStepQuestion() {
return "Question?";
}
@Override
public String getStepDescription() {
return "Description";
}
@Override
public StepResult executeStep(Object answer) {
return StepResult.success("Done");
}
}
private static class TestPersistableTask extends TestTask implements PersistableTask {
TestPersistableTask(TaskManager taskManager, String id) {
super(taskManager, id);
}
@Override
public void save() {
}
@Override
public void delete() {
}
@Override
public String toPayload() {
return "{}";
}
}
private static class TestDeletableTask extends TestTask implements Deletable {
private final AtomicBoolean deleted = new AtomicBoolean(false);
TestDeletableTask(TaskManager taskManager, String id) {
super(taskManager, id);
}
@Override
public void onInteractionDelete() {
deleted.set(true);
}
public boolean isDeleted() {
return deleted.get();
}
}
@Test
void testInsertAndLookup() {
TestTask task = new TestTask(taskManager, "task-1");
taskManager.insert(commandContext, task);
assertTrue(taskManager.hasTask("task-1"));
assertEquals(task, taskManager.getTask("task-1"));
assertTrue(taskManager.getTasks().contains(task));
}
@Test
void testInsertDuplicateRejected() {
TestTask task1 = new TestTask(taskManager, "duplicate-id");
TestTask task2 = new TestTask(taskManager, "duplicate-id");
taskManager.insert(commandContext, task1);
taskManager.insert(commandContext, task2);
verify(commandContext).reply("A task with the same ID already exists!");
assertEquals(1, taskManager.getTasks().size());
}
@Test
void testRemoveTask() {
TestTask task = new TestTask(taskManager, "task-to-remove");
taskManager.insert(commandContext, task);
assertTrue(taskManager.hasTask("task-to-remove"));
taskManager.removeTask(task);
assertFalse(taskManager.hasTask("task-to-remove"));
assertNull(taskManager.getTask("task-to-remove"));
}
@Test
void testParentAndChildInsertion() {
TestChildTask child1 = new TestChildTask(taskManager, "child-1", null);
TestChildTask child2 = new TestChildTask(taskManager, "child-2", null);
TestParentTask parent = new TestParentTask(taskManager, "parent-1", List.of(child1, child2));
taskManager.insert(commandContext, parent);
assertTrue(taskManager.hasTask("parent-1"));
assertTrue(taskManager.hasTask("child-1"));
assertTrue(taskManager.hasTask("child-2"));
}
@Test
void testChildRemoveSelfAndChildrenCascades() {
java.util.List<Task> children = new java.util.ArrayList<>();
TestParentTask parent = new TestParentTask(taskManager, "parent-task", children);
TestChildTask child1 = new TestChildTask(taskManager, "child-task-1", parent);
TestChildTask child2 = new TestChildTask(taskManager, "child-task-2", parent);
children.add(child1);
children.add(child2);
taskManager.insert(commandContext, parent);
assertTrue(taskManager.hasTask("parent-task"));
assertTrue(taskManager.hasTask("child-task-1"));
assertTrue(taskManager.hasTask("child-task-2"));
child1.setLifecycleToReturn(Task.Lifecycle.REMOVE_SELF_AND_CHILDREN);
when(buttonTaskContext.getId()).thenReturn("child-task-1");
when(buttonTaskContext.isAcknowledged()).thenReturn(true);
taskManager.handle(buttonTaskContext);
assertFalse(taskManager.hasTask("parent-task"));
assertFalse(taskManager.hasTask("child-task-1"));
assertFalse(taskManager.hasTask("child-task-2"));
}
@Test
void testParentRemoveSelfAndChildrenCascades() {
java.util.List<Task> children = new java.util.ArrayList<>();
TestParentTask parent = new TestParentTask(taskManager, "parent-task", children);
TestChildTask child1 = new TestChildTask(taskManager, "child-task-1", parent);
TestChildTask child2 = new TestChildTask(taskManager, "child-task-2", parent);
children.add(child1);
children.add(child2);
taskManager.insert(commandContext, parent);
parent.setLifecycleToReturn(Task.Lifecycle.REMOVE_SELF_AND_CHILDREN);
when(buttonTaskContext.getId()).thenReturn("parent-task");
when(buttonTaskContext.isAcknowledged()).thenReturn(true);
taskManager.handle(buttonTaskContext);
assertFalse(taskManager.hasTask("parent-task"));
assertFalse(taskManager.hasTask("child-task-1"));
assertFalse(taskManager.hasTask("child-task-2"));
}
@Test
void testChildRemoveSelfRemovesOnlySelf() {
java.util.List<Task> children = new java.util.ArrayList<>();
TestParentTask parent = new TestParentTask(taskManager, "parent-task", children);
TestChildTask child1 = new TestChildTask(taskManager, "child-task-1", parent);
TestChildTask child2 = new TestChildTask(taskManager, "child-task-2", parent);
children.add(child1);
children.add(child2);
taskManager.insert(commandContext, parent);
child1.setLifecycleToReturn(Task.Lifecycle.REMOVE_SELF);
when(buttonTaskContext.getId()).thenReturn("child-task-1");
when(buttonTaskContext.isAcknowledged()).thenReturn(true);
taskManager.handle(buttonTaskContext);
assertFalse(taskManager.hasTask("child-task-1"));
assertTrue(taskManager.hasTask("parent-task"));
assertTrue(taskManager.hasTask("child-task-2"));
}
@Test
void testStepTaskPendingAndActivation() {
TestStepTask stepTask = new TestStepTask(taskManager, "initial-uuid");
taskManager.insert(commandContext, stepTask);
assertTrue(taskManager.hasTask("initial-uuid"));
assertEquals(stepTask, taskManager.getTask("initial-uuid"));
assertTrue(taskManager.getTasks().contains(stepTask));
TestStepTask activatedTask = new TestStepTask(taskManager, "message-id-123");
taskManager.activateTask(activatedTask, "initial-uuid");
assertTrue(taskManager.hasTask("message-id-123"));
assertEquals(activatedTask, taskManager.getTask("message-id-123"));
assertFalse(taskManager.hasTask("initial-uuid"));
}
@Test
void testTaskCleanupPrunesExpiredStepTasks() {
TestStepTask expiredStepTask = new TestStepTask(taskManager, "expired-step");
expiredStepTask.setExpired(true);
TestStepTask activeStepTask = new TestStepTask(taskManager, "active-step");
activeStepTask.setExpired(false);
taskManager.insert(commandContext, expiredStepTask);
taskManager.insert(commandContext, activeStepTask);
TaskCleanup cleanup = new TaskCleanup(taskManager);
cleanup.run();
assertFalse(taskManager.hasTask("expired-step"));
assertTrue(taskManager.hasTask("active-step"));
}
@Test
void testPersistableTaskUnpersistedOnRemoval() {
TestPersistableTask persistableTask = new TestPersistableTask(taskManager, "persist-1");
taskManager.insert(commandContext, persistableTask);
verify(taskService).persistTask(persistableTask);
taskManager.removeTask(persistableTask);
verify(taskService).unpersistTask(persistableTask);
}
@Test
void testFindTaskByPartialId() {
TestButtonTask button1 = new TestButtonTask(taskManager, "btn_action_play");
TestButtonTask button2 = new TestButtonTask(taskManager, "btn_action_pause");
TestTask regularTask = new TestTask(taskManager, "regular_play");
taskManager.insert(commandContext, button1);
taskManager.insert(commandContext, button2);
taskManager.insert(commandContext, regularTask);
Optional<TestButtonTask> found = taskManager.findTaskByPartialId(TestButtonTask.class, "action_play");
assertTrue(found.isPresent());
assertEquals(button1, found.get());
List<TestButtonTask> allButtons = taskManager.findTasksByPartialId(TestButtonTask.class, "btn_action");
assertEquals(2, allButtons.size());
assertTrue(allButtons.contains(button1));
assertTrue(allButtons.contains(button2));
WithButton foundButton = taskManager.findButtonTaskByClass(TestButtonTask.class);
assertNotNull(foundButton);
}
@Test
void testConcurrentExecutionSharingLock() throws Exception {
int threadCount = 4;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch finishLatch = new CountDownLatch(threadCount);
AtomicInteger concurrentExecutions = new AtomicInteger(0);
AtomicInteger maxConcurrentSeen = new AtomicInteger(0);
String sharedLockId = "shared-lock-123";
for (int i = 0; i < threadCount; i++) {
String taskId = "task-lock-" + i;
TestTask task = new TestTask(taskManager, taskId, sharedLockId);
task.setLifecycleToReturn(Task.Lifecycle.KEEP);
task.setOnExecute(ctx -> {
int active = concurrentExecutions.incrementAndGet();
maxConcurrentSeen.updateAndGet(curr -> Math.max(curr, active));
try {
Thread.sleep(50);
} catch (InterruptedException ignored) {
}
concurrentExecutions.decrementAndGet();
});
taskManager.insert(commandContext, task);
executor.submit(() -> {
try {
startLatch.await();
TaskContext mockContext = mock(TaskContext.class);
when(mockContext.getId()).thenReturn(taskId);
when(mockContext.isAcknowledged()).thenReturn(true);
taskManager.handle(mockContext);
} catch (Exception e) {
e.printStackTrace();
} finally {
finishLatch.countDown();
}
});
}
startLatch.countDown();
boolean completed = finishLatch.await(5, TimeUnit.SECONDS);
executor.shutdown();
assertTrue(completed);
assertEquals(1, maxConcurrentSeen.get(), "Tasks sharing a lockId must be executed mutually exclusively");
}
@Test
void testDeletableInteractionExecution() {
TestDeletableTask deletableTask = new TestDeletableTask(taskManager, "deletable-1");
deletableTask.setLifecycleToReturn(Task.Lifecycle.REMOVE_SELF);
InteractionHook mockHook = mock(InteractionHook.class);
when(mockHook.isExpired()).thenReturn(false);
@SuppressWarnings("unchecked")
RestAction<Void> restAction = mock(RestAction.class);
when(mockHook.deleteOriginal()).thenReturn(restAction);
when(commandContext.getInteractionHook()).thenReturn(mockHook);
taskManager.insert(commandContext, deletableTask);
when(buttonTaskContext.getId()).thenReturn("deletable-1");
when(buttonTaskContext.isAcknowledged()).thenReturn(true);
taskManager.handle(buttonTaskContext);
assertTrue(deletableTask.isDeleted());
verify(mockHook).deleteOriginal();
verify(restAction).queue(any(), any());
}
}
@@ -0,0 +1,88 @@
package me.bobert.netneutrality.http;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import io.javalin.Javalin;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
class WebModuleTest {
private Javalin server;
private int port;
private final AtomicInteger hits = new AtomicInteger();
@BeforeEach
void setUp() {
server = Javalin.create(config -> config.showJavalinBanner = false);
server.get("/counter", ctx -> ctx.result("{\"hits\":" + hits.incrementAndGet() + "}"));
server.post("/counter", ctx -> ctx.result("{\"hits\":" + hits.incrementAndGet() + "}"));
server.start(0);
port = server.port();
}
@AfterEach
void tearDown() {
server.stop();
}
@Test
void getRequestsAreServedFromTheCache() {
String url = url("cache-hit");
assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request()
.getAsObject().getInt("hits"));
assertEquals(1, new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request()
.getAsObject().getInt("hits"));
assertEquals(1, hits.get());
}
@Test
void expiredEntriesAreRefetched() throws Exception {
String url = url("cache-expiry");
new WebModule.Builder(url).get().cache(1, TimeUnit.MILLISECONDS).build().request();
Thread.sleep(20);
new WebModule.Builder(url).get().cache(1, TimeUnit.MILLISECONDS).build().request();
assertEquals(2, hits.get());
}
@Test
void invalidateCacheForcesANewRequest() {
String url = url("cache-invalidate");
new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request();
WebModule.invalidateCache(url);
new WebModule.Builder(url).get().cache(1, TimeUnit.MINUTES).build().request();
assertEquals(2, hits.get());
}
@Test
void postRequestsBypassTheCache() {
String url = url("cache-post");
assertTrue(new WebModule.Builder(url).post(Map.of("a", "b")).build().request().isSuccess());
new WebModule.Builder(url).post(Map.of("a", "b")).build().request();
assertEquals(2, hits.get());
}
private String url(String marker) {
return "http://localhost:" + port + "/counter?marker=" + marker + "-" + System.nanoTime();
}
}
@@ -0,0 +1,172 @@
package me.bobert.netneutrality.http.server;
/*
* Copyright (c) anthorob
*
* https://discord.gg/qUa6zNFZsd
*/
import me.bobert.netneutrality.db.service.GuildService;
import me.bobert.netneutrality.db.service.GuildServiceProvider;
import me.bobert.netneutrality.db.service.GuildSettingService;
import me.bobert.netneutrality.utils.SettingsProvider;
import net.dv8tion.jda.api.JDA;
import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class WebApiTest {
private static final String GUILD_ID = "1234567890";
private WebApi webApi;
private int port;
private GuildSettingService settingService;
private final HttpClient http = HttpClient.newHttpClient();
@BeforeEach
void setUp() throws IOException {
port = freePort();
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn(GUILD_ID);
when(guild.getOwnerId()).thenReturn("42");
JDA jda = mock(JDA.class);
when(jda.getGuilds()).thenReturn(List.of(guild));
when(jda.getGuildById(GUILD_ID)).thenReturn(guild);
when(jda.getGuildById(any(String.class))).thenAnswer(inv ->
GUILD_ID.equals(inv.getArgument(0)) ? guild : null);
when(jda.getStatus()).thenReturn(JDA.Status.CONNECTED);
when(jda.getGatewayPing()).thenReturn(12L);
settingService = mock(GuildSettingService.class);
when(settingService.getSettings()).thenReturn(Map.of("prefix", "ü!"));
GuildService guildService = mock(GuildService.class);
when(guildService.getGuildSettingService()).thenReturn(settingService);
GuildServiceProvider provider = mock(GuildServiceProvider.class);
when(provider.get(guild)).thenReturn(guildService);
SettingsProvider settings = mock(SettingsProvider.class);
when(settings.get("NETNEUTRALITY_API_PORT", "9000")).thenReturn(String.valueOf(port));
webApi = new WebApi(settings, provider, () -> jda, () -> { });
webApi.start();
}
@AfterEach
void tearDown() {
webApi.stop();
}
@Test
void rootReturnsLivenessHtml() throws Exception {
HttpResponse<String> response = get("/");
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("Server start success"));
}
@Test
void statusReturnsHealthAndPing() throws Exception {
HttpResponse<String> response = get("/actions/status");
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"health\":\"CONNECTED\""));
assertTrue(response.body().contains("\"ping\":\"12\""));
}
@Test
void ownerReturnsGuildOwners() throws Exception {
HttpResponse<String> response = get("/guilds/owner");
assertEquals(200, response.statusCode());
assertEquals("[{\"guild_id\":\"" + GUILD_ID + "\",\"owner_id\":\"42\"}]", response.body());
}
@Test
void currentSettingsAreReturnedForKnownGuild() throws Exception {
HttpResponse<String> response = get("/actions/settings/current/" + GUILD_ID);
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"status\":\"ok\""));
assertTrue(response.body().contains("ü!"));
}
@Test
void unknownGuildReturnsNotFound() throws Exception {
HttpResponse<String> response = get("/guilds/channels/999");
assertEquals(404, response.statusCode());
assertTrue(response.body().contains("\"status\":\"error\""));
}
@Test
void unknownPathReturnsNotFound() throws Exception {
assertEquals(404, get("/nope").statusCode());
}
@Test
void wrongMethodIsNotRouted() throws Exception {
HttpRequest request = HttpRequest.newBuilder(uri("/guilds/owner"))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
assertEquals(404, http.send(request, HttpResponse.BodyHandlers.ofString()).statusCode());
}
@Test
void reloadRefreshesTheSettingCache() throws Exception {
HttpRequest request = HttpRequest.newBuilder(uri("/actions/settings/reload/" + GUILD_ID))
.POST(HttpRequest.BodyPublishers.noBody())
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString());
assertEquals(200, response.statusCode());
assertTrue(response.body().contains("\"status\":\"true\""));
verify(settingService).reloadSettingsCache();
}
@Test
void stopReleasesThePort() throws Exception {
webApi.stop();
try (ServerSocket socket = new ServerSocket(port)) {
assertEquals(port, socket.getLocalPort());
}
}
private HttpResponse<String> get(String path) throws Exception {
HttpRequest request = HttpRequest.newBuilder(uri(path)).GET().build();
return http.send(request, HttpResponse.BodyHandlers.ofString());
}
private URI uri(String path) {
return URI.create("http://localhost:" + port + path);
}
private static int freePort() throws IOException {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -5,9 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackInfo;
import dev.arbjerg.lavalink.client.player.Track;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import net.dv8tion.jda.api.entities.Guild;
import net.dv8tion.jda.api.entities.Member;
@@ -21,22 +19,20 @@ import static org.mockito.Mockito.*;
public class QueueManagerTest {
private AudioPlayer player;
private TrackScheduler scheduler;
private MusicManager musicManager;
private MusicManagerCallback callback;
private QueueManager queueManager;
@BeforeEach
void setUp() {
player = mock(AudioPlayer.class);
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
TrackScheduler scheduler = mock(TrackScheduler.class);
scheduler = mock(TrackScheduler.class);
musicManager = mock(MusicManager.class);
callback = mock(MusicManagerCallback.class);
when(scheduler.getPlayer()).thenReturn(player);
when(scheduler.getGuild()).thenReturn(guild);
when(scheduler.getMusicManager()).thenReturn(musicManager);
when(scheduler.getCallback()).thenReturn(callback);
@@ -44,11 +40,14 @@ public class QueueManagerTest {
queueManager = new QueueManager(scheduler);
}
private AudioTrack mockTrack(String id) {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn(id);
AudioTrackInfo info = new AudioTrackInfo("Title " + id, "Author", 1000L, id, false, "http://example.com/" + id);
static Track mockTrack(String id) {
Track track = mock(Track.class);
dev.arbjerg.lavalink.protocol.v4.TrackInfo info = mock(dev.arbjerg.lavalink.protocol.v4.TrackInfo.class);
when(info.getIdentifier()).thenReturn(id);
when(info.getTitle()).thenReturn("Title " + id);
when(info.getLength()).thenReturn(1000L);
when(track.getInfo()).thenReturn(info);
when(track.getEncoded()).thenReturn("encoded-" + id);
when(track.makeClone()).thenReturn(track);
return track;
}
@@ -64,23 +63,24 @@ public class QueueManagerTest {
@Test
public void queueAddsToTailAndStartsPlaybackWhenEmpty() {
AudioTrack track = mockTrack("t1");
Track track = mockTrack("t1");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null);
when(scheduler.getCurrentTrack()).thenReturn(null);
queueManager.queue(track, member);
assertEquals(1, queueManager.getQueueSize());
verify(player).startTrack(track, true);
verify(scheduler).setCurrentTrack(track);
verify(musicManager).updatePlayer(contains("t1"), any());
}
@Test
public void queueAddsToTailWhenAlreadyPlaying() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Track t1 = mockTrack("t1");
Track t2 = mockTrack("t2");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
queueManager.queue(t1, member);
queueManager.queue(t2, member);
@@ -93,31 +93,28 @@ public class QueueManagerTest {
@Test
public void queueAddsToFrontWhenNothingPlayingAndQueueNotEmpty() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Track t1 = mockTrack("t1");
Track t2 = mockTrack("t2");
Member member = mockMember("u1");
// First queue: nothing playing, queue empty -> add to tail, start track
when(player.getPlayingTrack()).thenReturn(null);
queueManager.queue(t1, member);
// Nothing playing on both calls: the second track jumps to the head so that it is the
// one actually started.
when(scheduler.getCurrentTrack()).thenReturn(null);
// Second queue: nothing playing (startTrack with noInterrupt=true didn't actually start),
// queue not empty -> should addFirst so t2 is at head and gets played
when(player.getPlayingTrack()).thenReturn(null);
queueManager.queue(t1, member);
queueManager.queue(t2, member);
assertEquals(2, queueManager.getQueueSize());
List<TrackInfo> q = queueManager.getQueue();
// t2 should be at the front since nothing was playing and queue was not empty
assertSame(t2, q.get(0).getTrack());
assertSame(t1, q.get(1).getTrack());
}
@Test
public void queueRejectsDuplicate() {
AudioTrack track = mockTrack("t1");
Track track = mockTrack("t1");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(track);
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(track);
queueManager.queue(track, member);
queueManager.queue(track, member);
@@ -136,7 +133,7 @@ public class QueueManagerTest {
@Test
public void clearQueueRemovesAllAndCallsCallback() {
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
queueManager.queue(mockTrack("t1"), mockMember("u1"));
queueManager.queue(mockTrack("t2"), mockMember("u1"));
@@ -158,10 +155,10 @@ public class QueueManagerTest {
@Test
public void skipSingleTrack() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Track t1 = mockTrack("t1");
Track t2 = mockTrack("t2");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
queueManager.queue(t1, member);
queueManager.queue(t2, member);
@@ -174,18 +171,16 @@ public class QueueManagerTest {
@Test
public void skipMoreThanQueueSizeSkipsAll() {
AudioTrack t1 = mockTrack("t1");
AudioTrack t2 = mockTrack("t2");
Track t1 = mockTrack("t1");
Track t2 = mockTrack("t2");
Member member = mockMember("u1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1).thenReturn(t1);
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
queueManager.queue(t1, member);
queueManager.queue(t2, member);
// skip(10) should clamp to queue.size() = 2, removing all
CommandExecutionResult result = queueManager.skip(10);
// After removing (nbTrack-1)=1 via poll, queue has 1 left, then startNextTrack polls that
// Actually with nbTrack clamped to 2: poll 1 track, then startNextTrack polls the last
queueManager.skip(10);
assertTrue(queueManager.isEmpty());
}
@@ -193,8 +188,8 @@ public class QueueManagerTest {
@Test
public void shuffleTooSmallQueueReturnsWarn() {
AudioTrack t1 = mockTrack("t1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(t1);
Track t1 = mockTrack("t1");
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(t1);
queueManager.queue(t1, mockMember("u1"));
CommandExecutionResult result = queueManager.shuffle();
@@ -204,10 +199,10 @@ public class QueueManagerTest {
@Test
public void shufflePreservesCurrentTrackAtHead() {
Member member = mockMember("u1");
AudioTrack playing = mockTrack("t1");
when(player.getPlayingTrack()).thenReturn(null).thenReturn(playing).thenReturn(playing).thenReturn(playing).thenReturn(playing);
Track playing = mockTrack("t1");
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(playing);
queueManager.queue(mockTrack("t1"), member);
queueManager.queue(playing, member);
queueManager.queue(mockTrack("t2"), member);
queueManager.queue(mockTrack("t3"), member);
queueManager.queue(mockTrack("t4"), member);
@@ -218,7 +213,7 @@ public class QueueManagerTest {
// First track should still be t1
List<TrackInfo> q = queueManager.getQueue();
assertEquals(4, q.size());
assertEquals("t1", q.get(0).getTrack().getIdentifier());
assertEquals("t1", q.get(0).getTrack().getInfo().getIdentifier());
}
// --- RemoveFromQueue ---
@@ -231,7 +226,7 @@ public class QueueManagerTest {
@Test
public void removeFromQueueOutOfBoundsReturnsError() {
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
queueManager.queue(mockTrack("t1"), mockMember("u1"));
CommandExecutionResult result = queueManager.removeFromQueue(5);
@@ -240,7 +235,7 @@ public class QueueManagerTest {
@Test
public void removeFromQueueIndexZeroReturnsNone() {
when(player.getPlayingTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
when(scheduler.getCurrentTrack()).thenReturn(null).thenReturn(mockTrack("t1"));
queueManager.queue(mockTrack("t1"), mockMember("u1"));
CommandExecutionResult result = queueManager.removeFromQueue(0);
@@ -5,7 +5,7 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import dev.arbjerg.lavalink.client.player.Track;
import net.dv8tion.jda.api.entities.Member;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -18,13 +18,13 @@ import static org.mockito.Mockito.when;
public class TrackInfoTest {
private AudioTrack track;
private Track track;
private Member member;
private TrackInfo trackInfo;
@BeforeEach
void setUp() {
track = mock(AudioTrack.class);
track = QueueManagerTest.mockTrack("t1");
member = mock(Member.class);
when(member.getId()).thenReturn("user1");
when(member.getEffectiveName()).thenReturn("User1");
@@ -101,7 +101,7 @@ public class TrackInfoTest {
@Test
public void notEqualWithDifferentTrack() {
AudioTrack otherTrack = mock(AudioTrack.class);
Track otherTrack = QueueManagerTest.mockTrack("t2");
TrackInfo other = new TrackInfo(otherTrack, member);
assertNotEquals(trackInfo, other);
}
@@ -5,28 +5,27 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.player.AudioPlayer;
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import com.sedmelluq.discord.lavaplayer.track.AudioTrackEndReason;
import dev.arbjerg.lavalink.client.player.Track;
import dev.arbjerg.lavalink.protocol.v4.Message.EmittedEvent.TrackEndEvent;
import net.dv8tion.jda.api.entities.Guild;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.Mockito.*;
public class TrackSchedulerTest {
private AudioPlayer player;
private MusicManager musicManager;
private TrackScheduler scheduler;
@BeforeEach
void setUp() {
player = mock(AudioPlayer.class);
Guild guild = mock(Guild.class);
when(guild.getId()).thenReturn("guild1");
MusicManager musicManager = mock(MusicManager.class);
when(musicManager.getPlayer()).thenReturn(player);
musicManager = mock(MusicManager.class);
when(musicManager.getGuild()).thenReturn(guild);
scheduler = new TrackScheduler(musicManager);
@@ -34,45 +33,51 @@ public class TrackSchedulerTest {
@Test
public void onTrackEndFinishedStartsNextTrack() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
Track track = QueueManagerTest.mockTrack("t1");
// With empty queue, startNextTrack will just stopTrack
scheduler.onTrackEnd(player, track, AudioTrackEndReason.FINISHED);
verify(player).stopTrack();
// With empty queue, startNextTrack will just stop playback on the node
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.FINISHED);
verify(musicManager).stopTrack();
}
@Test
public void onTrackEndLoadFailedStartsNextTrack() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
Track track = QueueManagerTest.mockTrack("t1");
scheduler.onTrackEnd(player, track, AudioTrackEndReason.LOAD_FAILED);
verify(player).stopTrack();
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.LOAD_FAILED);
verify(musicManager).stopTrack();
}
@Test
public void onTrackEndReplacedDoesNotStartNext() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
Track track = QueueManagerTest.mockTrack("t1");
scheduler.onTrackEnd(player, track, AudioTrackEndReason.REPLACED);
verify(player, never()).stopTrack();
verify(player, never()).startTrack(any(), anyBoolean());
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.REPLACED);
verify(musicManager, never()).stopTrack();
verify(musicManager, never()).updatePlayer(anyString(), any());
}
@Test
public void onTrackStartResetsStuckHandler() {
AudioTrack track = mock(AudioTrack.class);
when(track.getIdentifier()).thenReturn("t1");
public void onTrackStartCachesCurrentTrack() {
Track track = QueueManagerTest.mockTrack("t1");
// After onTrackStart, the stuck handler should be reset
scheduler.onTrackStart(player, track);
// No exception means reset was called successfully
scheduler.onTrackStart(track);
assertSame(track, scheduler.getCurrentTrack());
}
@Test
public void onTrackEndClearsCurrentTrack() {
Track track = QueueManagerTest.mockTrack("t1");
scheduler.onTrackStart(track);
scheduler.onTrackEnd(track, TrackEndEvent.AudioTrackEndReason.STOPPED);
assertNull(scheduler.getCurrentTrack());
}
@Test
public void getDjReturnsNullWhenNoContext() {
assert scheduler.getDj() == null;
assertNull(scheduler.getDj());
}
}
@@ -5,7 +5,6 @@ package me.bobert.netneutrality.music;
* https://discord.gg/qUa6zNFZsd
*/
import com.sedmelluq.discord.lavaplayer.track.AudioTrack;
import me.bobert.netneutrality.core.command.CommandExecutionResult;
import me.bobert.netneutrality.core.context.CommandContext;
import net.dv8tion.jda.api.entities.Guild;
@@ -39,7 +38,7 @@ public class VoteSkipServiceTest {
when(guild.getId()).thenReturn("guild1");
when(context.getGuild()).thenReturn(guild);
AudioTrack track = mock(AudioTrack.class);
dev.arbjerg.lavalink.client.player.Track track = QueueManagerTest.mockTrack("t1");
Member requester = mock(Member.class);
when(requester.getId()).thenReturn("requester");
trackInfo = new TrackInfo(track, requester);