feat: Initial commit
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
.idea/**
|
||||
target/**
|
||||
**.iml
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# ── Stage 1: Build ────────────────────────────────────────────────────────────
|
||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY pom.xml .
|
||||
COPY src ./src
|
||||
|
||||
# Download dependencies (cached layer), then build
|
||||
RUN apk add --no-cache maven && \
|
||||
mvn dependency:go-offline -q && \
|
||||
mvn package -DskipTests -q
|
||||
|
||||
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the fat JAR from the build stage
|
||||
COPY --from=build /app/target/overseerr-webhook-proxy-*.jar app.jar
|
||||
|
||||
# The application.yml can be overridden by mounting a file at:
|
||||
# /app/config/application.yml
|
||||
VOLUME ["/app/config"]
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
ENTRYPOINT ["java", \
|
||||
"-Dspring.config.additional-location=optional:file:/app/config/application.yml", \
|
||||
"-jar", "app.jar"]
|
||||
@@ -0,0 +1,104 @@
|
||||
# Overseerr Webhook Proxy
|
||||
|
||||
A lightweight Spring Boot proxy that receives [Overseerr](https://overseerr.dev/) /
|
||||
[Jellyseerr](https://github.com/Fallenbagel/jellyseerr) webhook notifications and
|
||||
forwards them to **multiple Discord servers**, with per-notification-type routing.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
Overseerr ──POST /webhook──► Proxy ──► Discord Server A (Admins)
|
||||
──► Discord Server B (General)
|
||||
──► Discord Server C (Film fans)
|
||||
```
|
||||
|
||||
Each Discord embed is colour-coded by notification type and includes the media
|
||||
title, requester name, and media type.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
### 1. Clone & configure
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourname/overseerr-webhook-proxy
|
||||
cd overseerr-webhook-proxy
|
||||
mkdir config
|
||||
cp src/main/resources/application.yml config/application.yml
|
||||
```
|
||||
|
||||
Edit `config/application.yml` and replace the placeholder webhook URLs with your
|
||||
real Discord webhook URLs.
|
||||
|
||||
### 2. Run with Docker Compose
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### 3. Configure Overseerr
|
||||
|
||||
In Overseerr → **Settings → Notifications → Webhook**:
|
||||
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| Webhook URL | `http://<your-server-ip>:8080/webhook` |
|
||||
| JSON payload | *(leave as default)* |
|
||||
|
||||
Click **Test** to send a test notification and verify it arrives in Discord.
|
||||
|
||||
---
|
||||
|
||||
## Configuration reference (`application.yml`)
|
||||
|
||||
```yaml
|
||||
proxy:
|
||||
routes:
|
||||
MEDIA_APPROVED:
|
||||
- https://discord.com/api/webhooks/ID1/TOKEN1 # admins
|
||||
- https://discord.com/api/webhooks/ID2/TOKEN2 # general
|
||||
|
||||
MEDIA_AVAILABLE:
|
||||
- https://discord.com/api/webhooks/ID2/TOKEN2 # general only
|
||||
|
||||
MEDIA_FAILED:
|
||||
- https://discord.com/api/webhooks/ID1/TOKEN1 # admins only
|
||||
|
||||
default: # catch-all
|
||||
- https://discord.com/api/webhooks/ID1/TOKEN1
|
||||
```
|
||||
|
||||
### Supported notification types
|
||||
|
||||
| Type | Description |
|
||||
|------|-------------|
|
||||
| `MEDIA_PENDING` | New request submitted |
|
||||
| `MEDIA_APPROVED` | Request approved |
|
||||
| `MEDIA_DECLINED` | Request declined |
|
||||
| `MEDIA_AVAILABLE` | Media is now available |
|
||||
| `MEDIA_FAILED` | Download/processing failed |
|
||||
| `TEST_NOTIFICATION` | Test from Overseerr settings |
|
||||
| `default` | Catches any type not listed above |
|
||||
|
||||
---
|
||||
|
||||
## Building without Docker
|
||||
|
||||
Requirements: Java 21, Maven 3.9+
|
||||
|
||||
```bash
|
||||
mvn package -DskipTests
|
||||
java -jar target/overseerr-webhook-proxy-1.0.0.jar \
|
||||
--spring.config.additional-location=file:./config/application.yml
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/webhook` | Receives Overseerr notifications |
|
||||
| `GET` | `/health` | Simple liveness check |
|
||||
| `GET` | `/actuator/health` | Spring Boot Actuator health |
|
||||
@@ -0,0 +1,54 @@
|
||||
services:
|
||||
webhook-proxy:
|
||||
build: .
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
# Mount your custom config here — overrides the defaults inside the image
|
||||
- ./config:/app/config:ro
|
||||
environment:
|
||||
# Optional: override the port
|
||||
- LOG_LEVEL=debug
|
||||
- SERVER_PORT=8080
|
||||
networks:
|
||||
- seerr-net
|
||||
|
||||
# ── Jellyseerr (local test instance) ─────────────────────────────────────
|
||||
# Access the UI at: http://localhost:5055
|
||||
#
|
||||
# After first start, go through the setup wizard. When asked for a media
|
||||
# server you can skip it or point to a local Jellyfin (see below).
|
||||
#
|
||||
# Webhook setup in Jellyseerr:
|
||||
# Settings → Notifications → Webhook
|
||||
# URL: http://webhook-proxy:8080/webhook ← uses the internal Docker hostname
|
||||
# Payload: leave as default
|
||||
seerr:
|
||||
image: seerr/seerr:latest
|
||||
container_name: seerr
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5055:5055"
|
||||
environment:
|
||||
- LOG_LEVEL=debug
|
||||
- TZ=Europe/Berlin
|
||||
networks:
|
||||
- seerr-net
|
||||
|
||||
# ── Jellyfin (optional — only needed if Jellyseerr asks for a media server)
|
||||
# Access the UI at: http://localhost:8096
|
||||
# Remove this block if you don't want a full media server locally.
|
||||
jellyfin:
|
||||
image: jellyfin/jellyfin:latest
|
||||
container_name: jellyfin
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8096:8096"
|
||||
environment:
|
||||
- TZ=Europe/Berlin
|
||||
networks:
|
||||
- seerr-net
|
||||
|
||||
networks:
|
||||
seerr-net:
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"notification_type": "TEST_NOTIFICATION",
|
||||
"event": "",
|
||||
"subject": "Test Notification",
|
||||
"message": "Check check, 1, 2, 3. Are we coming in clear?",
|
||||
"image": "",
|
||||
"media": null,
|
||||
"request": null,
|
||||
"issue": null,
|
||||
"comment": null,
|
||||
"extra": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"notification_type": "MEDIA_AUTO_APPROVED",
|
||||
"event": "Movie Request Automatically Approved",
|
||||
"subject": "Crime 101 (2026)",
|
||||
"message": "When an elusive thief whose high-stakes heists unfold along the iconic 101 freeway in Los Angeles eyes the score of a lifetime, with hopes of this being his final job, his path collides with a disillusioned insurance broker who is facing her own crossroads. Determined to crack the case, a relentless detective closes in on the operation, raising the stakes even higher.",
|
||||
"image": "https://image.tmdb.org/t/p/w600_and_h900_bestv2/8L1IKxEfrFHmo2Zg0qjL9yAMnbP.jpg",
|
||||
"media": {
|
||||
"media_type": "movie",
|
||||
"tmdbId": "1171145",
|
||||
"tvdbId": "",
|
||||
"status": "PENDING",
|
||||
"status4k": "UNKNOWN"
|
||||
},
|
||||
"request": {
|
||||
"request_id": "2",
|
||||
"requestedBy_email": "test@example.com",
|
||||
"requestedBy_username": "root",
|
||||
"requestedBy_avatar": "/avatarproxy/8d952445af6245d992e193d7e5e092c3?v=undefined",
|
||||
"requestedBy_settings_discordId": "",
|
||||
"requestedBy_settings_telegramChatId": ""
|
||||
},
|
||||
"issue": null,
|
||||
"comment": null,
|
||||
"extra": []
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<relativePath/>
|
||||
</parent>
|
||||
|
||||
<groupId>dev.webhookproxy</groupId>
|
||||
<artifactId>overseerr-webhook-proxy</artifactId>
|
||||
<version>1.0.0</version>
|
||||
<name>overseerr-webhook-proxy</name>
|
||||
<description>Proxy that forwards Overseerr webhooks to multiple Discord servers</description>
|
||||
|
||||
<properties>
|
||||
<java.version>21</java.version>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
</configuration>
|
||||
</plugin>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-compiler-plugin</artifactId>
|
||||
<configuration>
|
||||
<source>11</source>
|
||||
<target>11</target>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
@@ -0,0 +1,14 @@
|
||||
package dev.webhookproxy;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties
|
||||
public class WebhookProxyApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(WebhookProxyApplication.class, args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package dev.webhookproxy.config;
|
||||
|
||||
import lombok.*;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "proxy")
|
||||
public class ProxyConfig {
|
||||
|
||||
/**
|
||||
* Map of notification_type → list of Discord webhook URLs.
|
||||
* Use the key "default" to catch all unmatched notification types.
|
||||
*/
|
||||
private List<String> routes = List.of();
|
||||
private Filters filters;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class Filters {
|
||||
private List<String> usernameFilter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package dev.webhookproxy.controller;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.webhookproxy.config.ProxyConfig;
|
||||
import dev.webhookproxy.model.SeerrPayload;
|
||||
import dev.webhookproxy.service.DiscordForwardingService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* Receives incoming Overseerr webhook POST requests and
|
||||
* delegates forwarding to {@link DiscordForwardingService}.
|
||||
*
|
||||
* Endpoint: POST /webhook
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
public class WebhookController {
|
||||
|
||||
private final DiscordForwardingService forwardingService;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ProxyConfig proxyConfig;
|
||||
|
||||
/**
|
||||
* Main webhook endpoint.
|
||||
* Overseerr should be configured to POST to: http://<your-host>:<port>/webhook
|
||||
*/
|
||||
@PostMapping("/webhook")
|
||||
public ResponseEntity<String> receiveWebhook(@RequestBody String rawBody) {
|
||||
log.info("Received webhook payload: {}", rawBody);
|
||||
|
||||
SeerrPayload payload;
|
||||
try {
|
||||
payload = objectMapper.readValue(rawBody, SeerrPayload.class);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to parse webhook payload: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest().body("Invalid JSON payload");
|
||||
}
|
||||
|
||||
log.info("Incoming notification type: {}", payload.getNotificationType());
|
||||
|
||||
if (payload.getRequest() != null) {
|
||||
if (proxyConfig.getFilters().getUsernameFilter().contains(payload.getRequest().getRequestedByUsername())) {
|
||||
String user = payload.getRequest().getRequestedByUsername();
|
||||
String movie = payload.getSubject();
|
||||
log.info("Blocked notifications for user {} and movie {}", user, movie);
|
||||
return ResponseEntity.ok("OK");
|
||||
}
|
||||
}
|
||||
|
||||
// Forward asynchronously — we return 200 immediately to Overseerr
|
||||
forwardingService.forward(payload, rawBody);
|
||||
|
||||
return ResponseEntity.ok("OK");
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple health / test endpoint.
|
||||
*/
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<String> health() {
|
||||
return ResponseEntity.ok("Webhook proxy is running.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package dev.webhookproxy.model;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
import com.fasterxml.jackson.annotation.JsonProperty;
|
||||
import lombok.Data;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Slf4j
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public class SeerrPayload {
|
||||
|
||||
@JsonProperty("notification_type")
|
||||
private String notificationType;
|
||||
|
||||
/** Human-friendly description of the event, e.g. "Movie Request Approved". */
|
||||
private String event;
|
||||
|
||||
/** Media title (or notification subject for non-media events). */
|
||||
private String subject;
|
||||
|
||||
/** Media overview / synopsis, or issue description. */
|
||||
private String message;
|
||||
|
||||
/** URL to the media poster image. */
|
||||
private String image;
|
||||
|
||||
private Media media;
|
||||
private Request request;
|
||||
private Issue issue;
|
||||
private Comment comment;
|
||||
private List<Extra> extra;
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Media {
|
||||
|
||||
@JsonProperty("media_type")
|
||||
private String mediaType;
|
||||
|
||||
private String tmdbId;
|
||||
private String tvdbId;
|
||||
|
||||
/** Availability status: UNKNOWN | PENDING | PROCESSING | PARTIALLY_AVAILABLE | AVAILABLE */
|
||||
private String status;
|
||||
|
||||
/** 4K availability status (same values as status). */
|
||||
private String status4k;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Request {
|
||||
|
||||
@JsonProperty("request_id")
|
||||
private String requestId;
|
||||
|
||||
@JsonProperty("requestedBy_email")
|
||||
private String requestedByEmail;
|
||||
|
||||
@JsonProperty("requestedBy_username")
|
||||
private String requestedByUsername;
|
||||
|
||||
@JsonProperty("requestedBy_avatar")
|
||||
private String requestedByAvatar;
|
||||
|
||||
@JsonProperty("requestedBy_settings_discordId")
|
||||
private String requestedByDiscordId;
|
||||
|
||||
@JsonProperty("requestedBy_settings_telegramChatId")
|
||||
private String requestedByTelegramChatId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Issue {
|
||||
|
||||
@JsonProperty("issue_id")
|
||||
private String issueId;
|
||||
|
||||
/** VIDEO | AUDIO | SUBTITLES | OTHER */
|
||||
@JsonProperty("issue_type")
|
||||
private String issueType;
|
||||
|
||||
/** OPEN | IN_PROGRESS | RESOLVED */
|
||||
@JsonProperty("issue_status")
|
||||
private String issueStatus;
|
||||
|
||||
@JsonProperty("reportedBy_email")
|
||||
private String reportedByEmail;
|
||||
|
||||
@JsonProperty("reportedBy_username")
|
||||
private String reportedByUsername;
|
||||
|
||||
@JsonProperty("reportedBy_avatar")
|
||||
private String reportedByAvatar;
|
||||
|
||||
@JsonProperty("reportedBy_settings_discordId")
|
||||
private String reportedByDiscordId;
|
||||
|
||||
@JsonProperty("reportedBy_settings_telegramChatId")
|
||||
private String reportedByTelegramChatId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Comment {
|
||||
|
||||
@JsonProperty("comment_message")
|
||||
private String commentMessage;
|
||||
|
||||
@JsonProperty("commentedBy_email")
|
||||
private String commentedByEmail;
|
||||
|
||||
@JsonProperty("commentedBy_username")
|
||||
private String commentedByUsername;
|
||||
|
||||
@JsonProperty("commentedBy_avatar")
|
||||
private String commentedByAvatar;
|
||||
|
||||
@JsonProperty("commentedBy_settings_discordId")
|
||||
private String commentedByDiscordId;
|
||||
|
||||
@JsonProperty("commentedBy_settings_telegramChatId")
|
||||
private String commentedByTelegramChatId;
|
||||
}
|
||||
|
||||
|
||||
@Data
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class Extra {
|
||||
private String name;
|
||||
private String value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package dev.webhookproxy.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import dev.webhookproxy.config.ProxyConfig;
|
||||
import dev.webhookproxy.model.SeerrPayload;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
/**
|
||||
* Builds a Discord embed message from the Overseerr payload
|
||||
* and forwards it concurrently to all configured webhook URLs.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DiscordForwardingService {
|
||||
|
||||
private final ProxyConfig proxyConfig;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* Forwards the payload to all Discord webhooks matching the notification type.
|
||||
*
|
||||
* @param payload the parsed Overseerr payload
|
||||
* @param rawBody the original raw JSON body (used as fallback)
|
||||
*/
|
||||
public void forward(SeerrPayload payload, String rawBody) {
|
||||
String mediaName = payload.getSubject();
|
||||
List<String> targets = proxyConfig.getRoutes();
|
||||
|
||||
if (targets.isEmpty()) {
|
||||
log.warn("No Discord webhooks configured — skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Forwarding notification for media {} to {} Discord webhook(s).", mediaName, targets.size());
|
||||
|
||||
String discordBody = buildDiscordPayload(payload);
|
||||
|
||||
// Fire all requests concurrently
|
||||
List<CompletableFuture<Void>> futures = new ArrayList<>();
|
||||
for (String webhookUrl : targets) {
|
||||
futures.add(sendAsync(webhookUrl, discordBody, mediaName));
|
||||
}
|
||||
|
||||
// Wait for all to complete (best-effort, errors are logged but not re-thrown)
|
||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private CompletableFuture<Void> sendAsync(String webhookUrl, String body, String notificationType) {
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(URI.create(webhookUrl))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
|
||||
return httpClient.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
||||
.thenAccept(response -> {
|
||||
if (response.statusCode() >= 200 && response.statusCode() < 300) {
|
||||
log.info("[{}] ✓ Delivered to {} (HTTP {})", notificationType,
|
||||
maskUrl(webhookUrl), response.statusCode());
|
||||
} else {
|
||||
log.error("[{}] ✗ Failed for {} — HTTP {} — Body: {}", notificationType,
|
||||
maskUrl(webhookUrl), response.statusCode(), response.body());
|
||||
}
|
||||
})
|
||||
.exceptionally(ex -> {
|
||||
log.error("[{}] ✗ Exception while sending to {}: {}",
|
||||
notificationType, maskUrl(webhookUrl), ex.getMessage());
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a Discord-compatible JSON payload with an embed.
|
||||
* Discord webhooks expect: { "embeds": [ { "title": "...", ... } ] }
|
||||
*/
|
||||
private String buildDiscordPayload(SeerrPayload payload) {
|
||||
try {
|
||||
int color = colorForType(payload.getNotificationType());
|
||||
|
||||
Map<String, Object> embed = new java.util.LinkedHashMap<>();
|
||||
embed.put("title", payload.getSubject() != null ? payload.getSubject() : "Overseerr Notification");
|
||||
embed.put("description", payload.getMessage() != null ? payload.getMessage() : "");
|
||||
embed.put("color", color);
|
||||
embed.put("author", Map.of("name", payload.getEvent()));
|
||||
|
||||
if (payload.getImage() != null && !payload.getImage().isBlank()) {
|
||||
embed.put("thumbnail", Map.of("url", payload.getImage()));
|
||||
}
|
||||
|
||||
// Field: requested by
|
||||
List<Map<String, Object>> fields = new ArrayList<>();
|
||||
if (payload.getRequest() != null && payload.getRequest().getRequestedByUsername() != null) {
|
||||
fields.add(field("Requested by", payload.getRequest().getRequestedByUsername(), true));
|
||||
}
|
||||
if (payload.getMedia() != null && payload.getMedia().getMediaType() != null) {
|
||||
fields.add(field("Media type", payload.getMedia().getMediaType(), true));
|
||||
}
|
||||
|
||||
if (payload.getMedia() != null) {
|
||||
fields.add(field("Request Status", capitalize(payload.getMedia().getStatus()), true));
|
||||
}
|
||||
|
||||
if (payload.getMedia() != null && payload.getMedia().getMediaType().equalsIgnoreCase("tv")) {
|
||||
String requestedSeasons = payload.getExtra()
|
||||
.stream()
|
||||
.filter(extra -> extra.getName().equalsIgnoreCase("Requested Seasons"))
|
||||
.findFirst()
|
||||
.map(SeerrPayload.Extra::getValue)
|
||||
.orElse("");
|
||||
|
||||
if (!requestedSeasons.isBlank()) {
|
||||
fields.add(field("Requested Seasons", requestedSeasons, true));
|
||||
}
|
||||
}
|
||||
|
||||
embed.put("fields", fields);
|
||||
embed.put("timestamp", Instant.now().toString());
|
||||
|
||||
Map<String, Object> discordPayload = Map.of("embeds", List.of(embed));
|
||||
return objectMapper.writeValueAsString(discordPayload);
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to build Discord payload, using fallback.", e);
|
||||
// Minimal fallback
|
||||
return "{\"content\":\"Overseerr notification: " + payload.getNotificationType() + "\"}";
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> field(String name, String value, boolean inline) {
|
||||
return Map.of("name", name, "value", value, "inline", inline);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Discord embed color (decimal) based on the notification type.
|
||||
*/
|
||||
private int colorForType(String type) {
|
||||
if (type == null) return 0x95A5A6; // grey
|
||||
return switch (type) {
|
||||
case "MEDIA_APPROVED" -> 0x6366F1; // green
|
||||
case "MEDIA_AVAILABLE" -> 0x2ECC71; // blue
|
||||
case "MEDIA_AUTO_APPROVED" -> 0x6366F1; // indigo/lila
|
||||
default -> 0x95A5A6; // grey
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Masks the webhook token in the URL for safe logging.
|
||||
* Input: https://discord.com/api/webhooks/1234567890/AbCdEfGhIj...
|
||||
* Output: https://discord.com/api/webhooks/1234567890/Ab***
|
||||
*/
|
||||
private String maskUrl(String url) {
|
||||
int lastSlash = url.lastIndexOf('/');
|
||||
if (lastSlash < 0 || lastSlash >= url.length() - 1) return url;
|
||||
String token = url.substring(lastSlash + 1);
|
||||
String masked = token.substring(0, Math.min(2, token.length())) + "***";
|
||||
return url.substring(0, lastSlash + 1) + masked;
|
||||
}
|
||||
|
||||
private String capitalize(String input) {
|
||||
if (input == null || input.isBlank()) return "";
|
||||
char firstChar = input.charAt(0);
|
||||
char upperCasedFirstChar = Character.toUpperCase(firstChar);
|
||||
String lowerCasLastPart = input.substring(1).toLowerCase();
|
||||
return upperCasedFirstChar + lowerCasLastPart;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
proxy:
|
||||
filters:
|
||||
usernameFilter:
|
||||
- private
|
||||
routes:
|
||||
- https://discord.com/api/webhooks/1498794785475920022/bwoytZA_iDvza86fbR9aOFXNOcv9_Fl5P5taoOaMHjey_X3YLRt5FGbDjt9uPiKHJ8yi
|
||||
|
||||
# Spring Boot Actuator — exposes /actuator/health endpoint
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health, info
|
||||
|
||||
logging:
|
||||
level:
|
||||
dev.webhookproxy: DEBUG
|
||||
@@ -0,0 +1,20 @@
|
||||
server:
|
||||
port: 8080
|
||||
|
||||
proxy:
|
||||
filters:
|
||||
usernameFilter:
|
||||
- private
|
||||
routes:
|
||||
- https://discord.com/api/webhooks/1498794785475920022/bwoytZA_iDvza86fbR9aOFXNOcv9_Fl5P5taoOaMHjey_X3YLRt5FGbDjt9uPiKHJ8yi
|
||||
|
||||
# Spring Boot Actuator — exposes /actuator/health endpoint
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health, info
|
||||
|
||||
logging:
|
||||
level:
|
||||
dev.webhookproxy: DEBUG
|
||||
@@ -0,0 +1,5 @@
|
||||
/home/mathias/Downloads/overseerr-webhook-proxy/src/main/java/dev/webhookproxy/WebhookProxyApplication.java
|
||||
/home/mathias/Downloads/overseerr-webhook-proxy/src/main/java/dev/webhookproxy/config/ProxyConfig.java
|
||||
/home/mathias/Downloads/overseerr-webhook-proxy/src/main/java/dev/webhookproxy/model/SeerrPayload.java
|
||||
/home/mathias/Downloads/overseerr-webhook-proxy/src/main/java/dev/webhookproxy/service/DiscordForwardingService.java
|
||||
/home/mathias/Downloads/overseerr-webhook-proxy/src/main/java/dev/webhookproxy/controller/WebhookController.java
|
||||
Reference in New Issue
Block a user