5 Commits

Author SHA1 Message Date
taiqane b7df5a3fde build: use distroless base image for new Dockerfile
/ build (push) Successful in 16s
/ build-docker-nightly (push) Has been skipped
/ build-docker-release (push) Successful in 7s
2026-04-29 00:58:49 +02:00
taiqane 17f7dc2e0d Revert "ci: use job token instead of pat"
/ build-docker-release (push) Has been skipped
/ build-docker-nightly (push) Successful in 7s
/ build (push) Successful in 16s
This reverts commit 12af1b2981.
2026-04-29 00:50:08 +02:00
taiqane 12af1b2981 ci: use job token instead of pat
/ build (push) Successful in 17s
/ build-docker-release (push) Has been skipped
/ build-docker-nightly (push) Failing after 5s
2026-04-29 00:43:12 +02:00
taiqane 1f8d050325 build: remove target directory
/ build (push) Successful in 14s
/ build-docker-nightly (push) Successful in 7s
/ build-docker-release (push) Has been skipped
2026-04-29 00:30:29 +02:00
taiqane de437fd914 style: remove claude comments
/ build (push) Successful in 15s
/ build-docker-nightly (push) Successful in 35s
/ build-docker-release (push) Has been skipped
2026-04-29 00:27:21 +02:00
8 changed files with 6 additions and 126 deletions
+2 -11
View File
@@ -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
View File
@@ -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;
-20
View File
@@ -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