Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7df5a3fde | |||
| 17f7dc2e0d | |||
| 12af1b2981 | |||
| 1f8d050325 | |||
| de437fd914 |
+3
-12
@@ -1,28 +1,19 @@
|
|||||||
# ── Stage 1: Build ────────────────────────────────────────────────────────────
|
|
||||||
FROM eclipse-temurin:21-jdk-alpine AS build
|
FROM eclipse-temurin:21-jdk-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY pom.xml .
|
COPY pom.xml .
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
|
|
||||||
# Download dependencies (cached layer), then build
|
|
||||||
RUN apk add --no-cache maven && \
|
RUN apk add --no-cache maven && \
|
||||||
mvn dependency:go-offline -q && \
|
mvn dependency:go-offline -q && \
|
||||||
mvn package -DskipTests -q
|
mvn package -DskipTests -q
|
||||||
|
|
||||||
# ── Stage 2: Runtime ──────────────────────────────────────────────────────────
|
FROM gcr.io/distroless/java21-debian12:nonroot
|
||||||
FROM eclipse-temurin:21-jre-alpine
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy the fat JAR from the build stage
|
|
||||||
COPY --from=build /app/target/overseerr-webhook-proxy-*.jar app.jar
|
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", \
|
ENTRYPOINT ["java", \
|
||||||
"-Dspring.config.additional-location=optional:file:/app/config/application.yml", \
|
"-Dspring.config.additional-location=optional:file:/app/config/application.yml", \
|
||||||
"-jar", "app.jar"]
|
"-jar", "app.jar"]
|
||||||
+3
-25
@@ -1,44 +1,22 @@
|
|||||||
services:
|
services:
|
||||||
webhook-proxy:
|
webhook-proxy:
|
||||||
|
image: viziona.dev/taiqane/discord-webhook-proxy:nightly
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
|
||||||
ports:
|
ports:
|
||||||
- "8080:8080"
|
- "127.0.0.1:8080:8080"
|
||||||
volumes:
|
restart: unless-stopped
|
||||||
# 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:
|
networks:
|
||||||
- seerr-net
|
- 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:
|
seerr:
|
||||||
image: seerr/seerr:latest
|
image: seerr/seerr:latest
|
||||||
container_name: seerr
|
container_name: seerr
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "5055:5055"
|
- "5055:5055"
|
||||||
environment:
|
|
||||||
- LOG_LEVEL=debug
|
|
||||||
- TZ=Europe/Berlin
|
|
||||||
networks:
|
networks:
|
||||||
- seerr-net
|
- 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:
|
jellyfin:
|
||||||
image: jellyfin/jellyfin:latest
|
image: jellyfin/jellyfin:latest
|
||||||
container_name: jellyfin
|
container_name: jellyfin
|
||||||
|
|||||||
@@ -4,19 +4,13 @@ import lombok.*;
|
|||||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
@Component
|
@Component
|
||||||
@ConfigurationProperties(prefix = "proxy")
|
@ConfigurationProperties(prefix = "proxy")
|
||||||
public class ProxyConfig {
|
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 List<String> routes = List.of();
|
||||||
private Filters filters;
|
private Filters filters;
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
/**
|
|
||||||
* Receives incoming Overseerr webhook POST requests and
|
|
||||||
* delegates forwarding to {@link DiscordForwardingService}.
|
|
||||||
*
|
|
||||||
* Endpoint: POST /webhook
|
|
||||||
*/
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -24,10 +18,6 @@ public class WebhookController {
|
|||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final ProxyConfig proxyConfig;
|
private final ProxyConfig proxyConfig;
|
||||||
|
|
||||||
/**
|
|
||||||
* Main webhook endpoint.
|
|
||||||
* Overseerr should be configured to POST to: http://<your-host>:<port>/webhook
|
|
||||||
*/
|
|
||||||
@PostMapping("/webhook")
|
@PostMapping("/webhook")
|
||||||
public ResponseEntity<String> receiveWebhook(@RequestBody String rawBody) {
|
public ResponseEntity<String> receiveWebhook(@RequestBody String rawBody) {
|
||||||
log.info("Received webhook payload: {}", rawBody);
|
log.info("Received webhook payload: {}", rawBody);
|
||||||
@@ -51,17 +41,8 @@ public class WebhookController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward asynchronously — we return 200 immediately to Overseerr
|
|
||||||
forwardingService.forward(payload, rawBody);
|
forwardingService.forward(payload, rawBody);
|
||||||
|
|
||||||
return ResponseEntity.ok("OK");
|
return ResponseEntity.ok("OK");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple health / test endpoint.
|
|
||||||
*/
|
|
||||||
@GetMapping("/health")
|
|
||||||
public ResponseEntity<String> health() {
|
|
||||||
return ResponseEntity.ok("Webhook proxy is running.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,10 +23,7 @@ public class SeerrPayload {
|
|||||||
|
|
||||||
/** Media overview / synopsis, or issue description. */
|
/** Media overview / synopsis, or issue description. */
|
||||||
private String message;
|
private String message;
|
||||||
|
|
||||||
/** URL to the media poster image. */
|
|
||||||
private String image;
|
private String image;
|
||||||
|
|
||||||
private Media media;
|
private Media media;
|
||||||
private Request request;
|
private Request request;
|
||||||
private Issue issue;
|
private Issue issue;
|
||||||
@@ -39,14 +36,9 @@ public class SeerrPayload {
|
|||||||
|
|
||||||
@JsonProperty("media_type")
|
@JsonProperty("media_type")
|
||||||
private String mediaType;
|
private String mediaType;
|
||||||
|
|
||||||
private String tmdbId;
|
private String tmdbId;
|
||||||
private String tvdbId;
|
private String tvdbId;
|
||||||
|
|
||||||
/** Availability status: UNKNOWN | PENDING | PROCESSING | PARTIALLY_AVAILABLE | AVAILABLE */
|
|
||||||
private String status;
|
private String status;
|
||||||
|
|
||||||
/** 4K availability status (same values as status). */
|
|
||||||
private String status4k;
|
private String status4k;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,11 +72,9 @@ public class SeerrPayload {
|
|||||||
@JsonProperty("issue_id")
|
@JsonProperty("issue_id")
|
||||||
private String issueId;
|
private String issueId;
|
||||||
|
|
||||||
/** VIDEO | AUDIO | SUBTITLES | OTHER */
|
|
||||||
@JsonProperty("issue_type")
|
@JsonProperty("issue_type")
|
||||||
private String issueType;
|
private String issueType;
|
||||||
|
|
||||||
/** OPEN | IN_PROGRESS | RESOLVED */
|
|
||||||
@JsonProperty("issue_status")
|
@JsonProperty("issue_status")
|
||||||
private String issueStatus;
|
private String issueStatus;
|
||||||
|
|
||||||
|
|||||||
@@ -18,10 +18,6 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a Discord embed message from the Overseerr payload
|
|
||||||
* and forwards it concurrently to all configured webhook URLs.
|
|
||||||
*/
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -34,12 +30,6 @@ public class DiscordForwardingService {
|
|||||||
.connectTimeout(Duration.ofSeconds(5))
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
.build();
|
.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) {
|
public void forward(SeerrPayload payload, String rawBody) {
|
||||||
String mediaName = payload.getSubject();
|
String mediaName = payload.getSubject();
|
||||||
List<String> targets = proxyConfig.getRoutes();
|
List<String> targets = proxyConfig.getRoutes();
|
||||||
@@ -53,20 +43,14 @@ public class DiscordForwardingService {
|
|||||||
|
|
||||||
String discordBody = buildDiscordPayload(payload);
|
String discordBody = buildDiscordPayload(payload);
|
||||||
|
|
||||||
// Fire all requests concurrently
|
|
||||||
List<CompletableFuture<Void>> futures = new ArrayList<>();
|
List<CompletableFuture<Void>> futures = new ArrayList<>();
|
||||||
for (String webhookUrl : targets) {
|
for (String webhookUrl : targets) {
|
||||||
futures.add(sendAsync(webhookUrl, discordBody, mediaName));
|
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();
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
// Internal helpers
|
|
||||||
// -------------------------------------------------------------------------
|
|
||||||
|
|
||||||
private CompletableFuture<Void> sendAsync(String webhookUrl, String body, String notificationType) {
|
private CompletableFuture<Void> sendAsync(String webhookUrl, String body, String notificationType) {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest request = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(webhookUrl))
|
.uri(URI.create(webhookUrl))
|
||||||
@@ -92,10 +76,6 @@ public class DiscordForwardingService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a Discord-compatible JSON payload with an embed.
|
|
||||||
* Discord webhooks expect: { "embeds": [ { "title": "...", ... } ] }
|
|
||||||
*/
|
|
||||||
private String buildDiscordPayload(SeerrPayload payload) {
|
private String buildDiscordPayload(SeerrPayload payload) {
|
||||||
try {
|
try {
|
||||||
int color = colorForType(payload.getNotificationType());
|
int color = colorForType(payload.getNotificationType());
|
||||||
@@ -110,7 +90,6 @@ public class DiscordForwardingService {
|
|||||||
embed.put("thumbnail", Map.of("url", payload.getImage()));
|
embed.put("thumbnail", Map.of("url", payload.getImage()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Field: requested by
|
|
||||||
List<Map<String, Object>> fields = new ArrayList<>();
|
List<Map<String, Object>> fields = new ArrayList<>();
|
||||||
if (payload.getRequest() != null && payload.getRequest().getRequestedByUsername() != null) {
|
if (payload.getRequest() != null && payload.getRequest().getRequestedByUsername() != null) {
|
||||||
fields.add(field("Requested by", payload.getRequest().getRequestedByUsername(), true));
|
fields.add(field("Requested by", payload.getRequest().getRequestedByUsername(), true));
|
||||||
@@ -153,9 +132,6 @@ public class DiscordForwardingService {
|
|||||||
return Map.of("name", name, "value", value, "inline", 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) {
|
private int colorForType(String type) {
|
||||||
if (type == null) return 0x95A5A6; // grey
|
if (type == null) return 0x95A5A6; // grey
|
||||||
return switch (type) {
|
return switch (type) {
|
||||||
@@ -166,11 +142,6 @@ public class DiscordForwardingService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
private String maskUrl(String url) {
|
||||||
int lastSlash = url.lastIndexOf('/');
|
int lastSlash = url.lastIndexOf('/');
|
||||||
if (lastSlash < 0 || lastSlash >= url.length() - 1) return url;
|
if (lastSlash < 0 || lastSlash >= url.length() - 1) return url;
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
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
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
/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