7 Commits

Author SHA1 Message Date
taiqane d10e6b42c4 chore(deps): bump version of lombok and maven-compiler-plugin config
/ build (push) Successful in 22s
/ build-docker-nightly (push) Successful in 8s
/ build-docker-release (push) Has been skipped
2026-06-15 15:52:31 +02:00
taiqane 44c63c0f10 fix: put filters under routes and add route logging to startup
/ build-docker-release (push) Successful in 7s
/ build (push) Successful in 21s
/ build-docker-nightly (push) Has been skipped
2026-06-15 15:17:04 +02:00
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 (push) Successful in 16s
/ build-docker-release (push) Has been skipped
/ build-docker-nightly (push) Successful in 7s
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
10 changed files with 71 additions and 152 deletions
+3 -12
View File
@@ -1,28 +1,19 @@
# ── 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
FROM gcr.io/distroless/java21-debian12:nonroot
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"]
"-jar", "app.jar"]
+3 -25
View File
@@ -1,44 +1,22 @@
services:
webhook-proxy:
image: viziona.dev/taiqane/discord-webhook-proxy:nightly
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
- "127.0.0.1:8080:8080"
restart: unless-stopped
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
+3 -2
View File
@@ -42,6 +42,7 @@
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.46</version>
<optional>true</optional>
</dependency>
<dependency>
@@ -69,8 +70,8 @@
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>11</source>
<target>11</target>
<source>21</source>
<target>21</target>
</configuration>
</plugin>
</plugins>
@@ -1,30 +1,56 @@
package dev.webhookproxy.config;
import jakarta.annotation.PostConstruct;
import lombok.*;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
@Slf4j
@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;
private List<Route> routes = List.of();
@Getter
@Setter
@PostConstruct
public void logConfig() {
log.info("Loaded {} route(s):", routes.size());
for (int i = 0; i < routes.size(); i++) {
Route route = routes.get(i);
log.info(" [{}] {}", i + 1, maskUrl(route.getUrl()));
List<String> usernames = route.getFilters().getUsernameFilter();
if (usernames.isEmpty()) {
log.info(" usernameFilter: (none)");
} else {
log.info(" usernameFilter: {}", usernames);
}
}
}
private String maskUrl(String url) {
if (url == null) return "(null)";
int lastSlash = url.lastIndexOf('/');
if (lastSlash < 0 || lastSlash >= url.length() - 1) return url;
String token = url.substring(lastSlash + 1);
return url.substring(0, lastSlash + 1) + token.substring(0, Math.min(2, token.length())) + "***";
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Route {
private String url;
private Filters filters = new Filters();
}
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Filters {
private List<String> usernameFilter;
private List<String> usernameFilter = List.of();
}
}
@@ -9,12 +9,6 @@ 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
@@ -24,10 +18,6 @@ public class WebhookController {
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);
@@ -42,26 +32,8 @@ public class WebhookController {
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.");
}
}
@@ -23,10 +23,7 @@ public class SeerrPayload {
/** 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;
@@ -39,14 +36,9 @@ public class SeerrPayload {
@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;
}
@@ -80,11 +72,9 @@ public class SeerrPayload {
@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;
@@ -18,10 +18,6 @@ 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
@@ -34,38 +30,41 @@ public class DiscordForwardingService {
.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();
List<ProxyConfig.Route> routes = proxyConfig.getRoutes();
if (targets.isEmpty()) {
if (routes.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));
for (ProxyConfig.Route route : routes) {
if (isBlocked(route, payload)) {
log.info("Route {} blocked notification for user {} and media {}",
maskUrl(route.getUrl()),
payload.getRequest() != null ? payload.getRequest().getRequestedByUsername() : "unknown",
mediaName);
continue;
}
futures.add(sendAsync(route.getUrl(), discordBody, mediaName));
}
// Wait for all to complete (best-effort, errors are logged but not re-thrown)
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
if (!futures.isEmpty()) {
log.info("Forwarding notification for media {} to {} Discord webhook(s).", mediaName, futures.size());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
}
}
// -------------------------------------------------------------------------
// Internal helpers
// -------------------------------------------------------------------------
private boolean isBlocked(ProxyConfig.Route route, SeerrPayload payload) {
List<String> usernameFilter = route.getFilters().getUsernameFilter();
if (usernameFilter.isEmpty()) return false;
if (payload.getRequest() == null) return false;
return usernameFilter.contains(payload.getRequest().getRequestedByUsername());
}
private CompletableFuture<Void> sendAsync(String webhookUrl, String body, String notificationType) {
HttpRequest request = HttpRequest.newBuilder()
@@ -92,10 +91,6 @@ public class DiscordForwardingService {
});
}
/**
* 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());
@@ -110,7 +105,6 @@ public class DiscordForwardingService {
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));
@@ -153,9 +147,6 @@ public class DiscordForwardingService {
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) {
@@ -166,11 +157,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) {
int lastSlash = url.lastIndexOf('/');
if (lastSlash < 0 || lastSlash >= url.length() - 1) return url;
+4 -4
View File
@@ -2,11 +2,11 @@ server:
port: 8080
proxy:
filters:
usernameFilter:
- private
routes:
- https://discord.com/api/webhooks/1498794785475920022/bwoytZA_iDvza86fbR9aOFXNOcv9_Fl5P5taoOaMHjey_X3YLRt5FGbDjt9uPiKHJ8yi
- url: https://discord.com/api/webhooks/1498794785475920022/bwoytZA_iDvza86fbR9aOFXNOcv9_Fl5P5taoOaMHjey_X3YLRt5FGbDjt9uPiKHJ8yi
filters:
usernameFilter:
- private
# Spring Boot Actuator — exposes /actuator/health endpoint
management:
-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