Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d10e6b42c4 | |||
| 44c63c0f10 | |||
| b7df5a3fde | |||
| 17f7dc2e0d | |||
| 12af1b2981 | |||
| 1f8d050325 | |||
| de437fd914 |
+2
-11
@@ -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
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
<artifactId>lombok</artifactId>
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>1.18.46</version>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -69,8 +70,8 @@
|
|||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
<artifactId>maven-compiler-plugin</artifactId>
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
<configuration>
|
<configuration>
|
||||||
<source>11</source>
|
<source>21</source>
|
||||||
<target>11</target>
|
<target>21</target>
|
||||||
</configuration>
|
</configuration>
|
||||||
</plugin>
|
</plugin>
|
||||||
</plugins>
|
</plugins>
|
||||||
|
|||||||
@@ -1,30 +1,56 @@
|
|||||||
package dev.webhookproxy.config;
|
package dev.webhookproxy.config;
|
||||||
|
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
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;
|
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Data
|
@Data
|
||||||
@Component
|
@Component
|
||||||
@ConfigurationProperties(prefix = "proxy")
|
@ConfigurationProperties(prefix = "proxy")
|
||||||
public class ProxyConfig {
|
public class ProxyConfig {
|
||||||
|
|
||||||
/**
|
private List<Route> routes = List.of();
|
||||||
* 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
|
@PostConstruct
|
||||||
@Setter
|
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
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
public static class Filters {
|
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.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);
|
||||||
@@ -42,26 +32,8 @@ public class WebhookController {
|
|||||||
|
|
||||||
log.info("Incoming notification type: {}", payload.getNotificationType());
|
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);
|
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,38 +30,41 @@ 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<ProxyConfig.Route> routes = proxyConfig.getRoutes();
|
||||||
|
|
||||||
if (targets.isEmpty()) {
|
if (routes.isEmpty()) {
|
||||||
log.warn("No Discord webhooks configured — skipping.");
|
log.warn("No Discord webhooks configured — skipping.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Forwarding notification for media {} to {} Discord webhook(s).", mediaName, targets.size());
|
|
||||||
|
|
||||||
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 (ProxyConfig.Route route : routes) {
|
||||||
futures.add(sendAsync(webhookUrl, discordBody, mediaName));
|
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)
|
if (!futures.isEmpty()) {
|
||||||
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
log.info("Forwarding notification for media {} to {} Discord webhook(s).", mediaName, futures.size());
|
||||||
|
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
private boolean isBlocked(ProxyConfig.Route route, SeerrPayload payload) {
|
||||||
// Internal helpers
|
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) {
|
private CompletableFuture<Void> sendAsync(String webhookUrl, String body, String notificationType) {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
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) {
|
private String buildDiscordPayload(SeerrPayload payload) {
|
||||||
try {
|
try {
|
||||||
int color = colorForType(payload.getNotificationType());
|
int color = colorForType(payload.getNotificationType());
|
||||||
@@ -110,7 +105,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 +147,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 +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) {
|
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;
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ server:
|
|||||||
port: 8080
|
port: 8080
|
||||||
|
|
||||||
proxy:
|
proxy:
|
||||||
filters:
|
|
||||||
usernameFilter:
|
|
||||||
- private
|
|
||||||
routes:
|
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
|
# Spring Boot Actuator — exposes /actuator/health endpoint
|
||||||
management:
|
management:
|
||||||
|
|||||||
@@ -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