feat: Initial commit

This commit is contained in:
2026-04-29 00:02:58 +02:00
commit 1f4eba49cf
15 changed files with 786 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
.idea/**
target/**
**.iml
+28
View File
@@ -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"]
+104
View File
@@ -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 |
+54
View File
@@ -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:
+12
View File
@@ -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": []
}
+25
View File
@@ -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": []
}
+78
View File
@@ -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;
}
}
+20
View File
@@ -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
+20
View File
@@ -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