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