Skip to content

Commit 2f68757

Browse files
authored
initial changes for webhook functionality (#257)
1 parent 2109091 commit 2f68757

File tree

7 files changed

+250
-3
lines changed

7 files changed

+250
-3
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<template>
2+
<CardComponent title="Webhook">
3+
<template #description>
4+
Configure webhooks in Radarr and Sonarr to automatically trigger translations when new media is added.
5+
</template>
6+
<template #content>
7+
<div class="flex flex-col space-y-2">
8+
<span>
9+
Go to <b>Settings → Connect → Connections → +</b> and use this URL:
10+
</span>
11+
<span class="font-semibold">Radarr</span>
12+
<code class="bg-accent/20 mt-1 block rounded p-2 text-sm">
13+
{{ webhookUrl }}/api/webhook/radarr
14+
</code>
15+
<span class="font-semibold">Sonarr</span>
16+
<code class="bg-accent/20 mt-1 block rounded p-2 text-sm">
17+
{{ webhookUrl }}/api/webhook/sonarr
18+
</code>
19+
</div>
20+
</template>
21+
</CardComponent>
22+
</template>
23+
24+
<script setup lang="ts">
25+
import CardComponent from '@/components/common/CardComponent.vue'
26+
27+
const webhookUrl = window.location.origin
28+
</script>

Lingarr.Client/src/pages/settings/IntegrationPage.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,11 @@
22
<div
33
class="grid grid-flow-row auto-rows-max grid-cols-1 gap-4 p-4 xl:grid-cols-2 2xl:grid-cols-3">
44
<IntegrationSettings />
5+
<WebhookInstructions />
56
</div>
67
</template>
78

89
<script setup lang="ts">
910
import IntegrationSettings from '@/components/features/settings/IntegrationSettings.vue'
11+
import WebhookInstructions from '@/components/features/settings/WebhookInstructions.vue'
1012
</script>
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
using Hangfire;
2+
using Lingarr.Server.Attributes;
3+
using Lingarr.Server.Jobs;
4+
using Lingarr.Server.Models.Webhooks;
5+
using Microsoft.AspNetCore.Mvc;
6+
7+
namespace Lingarr.Server.Controllers;
8+
9+
[ApiController]
10+
[LingarrAuthorize]
11+
[Route("api/[controller]")]
12+
public class WebhookController : ControllerBase
13+
{
14+
private readonly IBackgroundJobClient _backgroundJobClient;
15+
private readonly ILogger<WebhookController> _logger;
16+
17+
public WebhookController(
18+
IBackgroundJobClient backgroundJobClient,
19+
ILogger<WebhookController> logger)
20+
{
21+
_backgroundJobClient = backgroundJobClient;
22+
_logger = logger;
23+
}
24+
25+
/// <summary>
26+
/// Receives webhook events from Radarr
27+
/// </summary>
28+
/// <param name="payload">The webhook payload from Radarr</param>
29+
/// <returns>Returns 200 OK if webhook was queued, 400 if invalid payload</returns>
30+
[HttpPost("radarr")]
31+
public async Task<IActionResult> RadarrWebhook([FromBody] RadarrWebhookPayload payload)
32+
{
33+
if (payload.Movie == null || payload.Movie.Id <= 0)
34+
{
35+
_logger.LogWarning("Invalid Radarr webhook payload: missing or invalid movie data");
36+
return BadRequest(new { message = "Invalid webhook payload: missing movie data" });
37+
}
38+
39+
_backgroundJobClient.Enqueue<WebhookJob>(job => job.ProcessRadarrWebhook(payload));
40+
_logger.LogInformation("Queued Radarr webhook processing job for movie ID {MovieId}", payload.Movie.Id);
41+
return Ok(new { message = "Webhook received and queued for processing" });
42+
}
43+
44+
/// <summary>
45+
/// Receives webhook events from Sonarr
46+
/// </summary>
47+
/// <param name="payload">The webhook payload from Sonarr</param>
48+
/// <returns>Returns 200 OK if webhook was queued, 400 if invalid payload</returns>
49+
[HttpPost("sonarr")]
50+
public async Task<IActionResult> SonarrWebhook([FromBody] SonarrWebhookPayload payload)
51+
{
52+
if (payload.Series == null || payload.Series.Id <= 0 || payload.Episodes == null || !payload.Episodes.Any())
53+
{
54+
_logger.LogWarning("Invalid Sonarr webhook payload: missing series or episode data");
55+
return BadRequest(new { message = "Invalid webhook payload: missing series or episode data" });
56+
}
57+
58+
_backgroundJobClient.Enqueue<WebhookJob>(job => job.ProcessSonarrWebhook(payload));
59+
_logger.LogInformation("Queued Sonarr webhook processing job for series ID {SeriesId}, episodes: {EpisodeIds}",
60+
payload.Series.Id, string.Join(", ", payload.Episodes.Select(e => e.Id)));
61+
return Ok(new { message = "Webhook received and queued for processing" });
62+
}
63+
}

Lingarr.Server/Jobs/WebhookJob.cs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using Hangfire;
2+
using Lingarr.Core.Data;
3+
using Lingarr.Core.Enum;
4+
using Lingarr.Server.Interfaces.Services;
5+
using Lingarr.Server.Models.Webhooks;
6+
using Microsoft.EntityFrameworkCore;
7+
8+
namespace Lingarr.Server.Jobs;
9+
10+
public class WebhookJob
11+
{
12+
private readonly LingarrDbContext _dbContext;
13+
private readonly IMediaService _mediaService;
14+
private readonly IMediaSubtitleProcessor _mediaSubtitleProcessor;
15+
private readonly ILogger<WebhookJob> _logger;
16+
17+
public WebhookJob(
18+
LingarrDbContext dbContext,
19+
IMediaService mediaService,
20+
IMediaSubtitleProcessor mediaSubtitleProcessor,
21+
ILogger<WebhookJob> logger)
22+
{
23+
_dbContext = dbContext;
24+
_mediaService = mediaService;
25+
_mediaSubtitleProcessor = mediaSubtitleProcessor;
26+
_logger = logger;
27+
}
28+
29+
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60)]
30+
[AutomaticRetry(Attempts = 3)]
31+
[Queue("webhook")]
32+
public async Task ProcessRadarrWebhook(RadarrWebhookPayload payload)
33+
{
34+
if (payload.Movie == null)
35+
{
36+
_logger.LogWarning("Radarr webhook payload has no movie data. Skipping.");
37+
return;
38+
}
39+
40+
try
41+
{
42+
var movieId = await _mediaService.GetMovieIdOrSyncFromRadarrMovieId(payload.Movie.Id);
43+
if (movieId == 0)
44+
{
45+
_logger.LogWarning("Failed to sync or find movie with Radarr ID {RadarrId}. Movie may not have a file yet.",
46+
payload.Movie.Id);
47+
return;
48+
}
49+
50+
var movie = await _dbContext.Movies.FirstOrDefaultAsync(m => m.Id == movieId);
51+
if (movie == null)
52+
{
53+
_logger.LogError("Movie with ID {MovieId} not found in database after sync", movieId);
54+
return;
55+
}
56+
57+
await _mediaSubtitleProcessor.ProcessMedia(movie, MediaType.Movie);
58+
}
59+
catch (Exception ex)
60+
{
61+
_logger.LogError(ex, "Error processing Radarr webhook for movie {MovieTitle} (Radarr ID: {RadarrId})",
62+
payload.Movie.Title, payload.Movie.Id);
63+
throw;
64+
}
65+
}
66+
67+
[DisableConcurrentExecution(timeoutInSeconds: 2 * 60)]
68+
[AutomaticRetry(Attempts = 3)]
69+
[Queue("webhook")]
70+
public async Task ProcessSonarrWebhook(SonarrWebhookPayload payload)
71+
{
72+
if (payload.Series == null || payload.Episodes == null || !payload.Episodes.Any())
73+
{
74+
_logger.LogWarning("Sonarr webhook payload has no series or episode data. Skipping.");
75+
return;
76+
}
77+
78+
try
79+
{
80+
foreach (var episode in payload.Episodes)
81+
{
82+
var episodeId = await _mediaService.GetEpisodeIdOrSyncFromSonarrEpisodeId(episode.Id);
83+
if (episodeId == 0)
84+
{
85+
_logger.LogWarning("Failed to sync or find episode with Sonarr ID {SonarrEpisodeId}. Episode may not have a file yet.",
86+
episode.Id);
87+
continue;
88+
}
89+
90+
var episodeEntity = await _dbContext.Episodes.FirstOrDefaultAsync(e => e.Id == episodeId);
91+
if (episodeEntity == null)
92+
{
93+
_logger.LogError("Episode with ID {EpisodeId} not found in database after sync", episodeId);
94+
continue;
95+
}
96+
97+
await _mediaSubtitleProcessor.ProcessMedia(episodeEntity, MediaType.Episode);
98+
}
99+
100+
_logger.LogInformation("Completed processing Sonarr webhook for series: {SeriesTitle}", payload.Series.Title);
101+
}
102+
catch (Exception ex)
103+
{
104+
_logger.LogError(ex, "Error processing Sonarr webhook for series {SeriesTitle} (Sonarr ID: {SonarrId})",
105+
payload.Series.Title, payload.Series.Id);
106+
throw;
107+
}
108+
}
109+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Lingarr.Server.Models.Webhooks;
4+
5+
public class RadarrWebhookPayload
6+
{
7+
[JsonPropertyName("movie")]
8+
public RadarrWebhookMovie? Movie { get; set; }
9+
}
10+
11+
public class RadarrWebhookMovie
12+
{
13+
[JsonPropertyName("id")]
14+
public int Id { get; set; }
15+
16+
[JsonPropertyName("title")]
17+
public string Title { get; set; } = string.Empty;
18+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Lingarr.Server.Models.Webhooks;
4+
5+
public class SonarrWebhookPayload
6+
{
7+
[JsonPropertyName("series")]
8+
public SonarrWebhookSeries? Series { get; set; }
9+
10+
[JsonPropertyName("episodes")]
11+
public List<SonarrWebhookEpisode>? Episodes { get; set; }
12+
}
13+
14+
public class SonarrWebhookSeries
15+
{
16+
[JsonPropertyName("id")]
17+
public int Id { get; set; }
18+
19+
[JsonPropertyName("title")]
20+
public string Title { get; set; } = string.Empty;
21+
}
22+
23+
public class SonarrWebhookEpisode
24+
{
25+
[JsonPropertyName("id")]
26+
public int Id { get; set; }
27+
}

Readme.MD

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,9 @@ Lingarr provides multi-architecture Docker images that automatically select the
3434

3535
| Tag | Description | Architectures |
3636
|-----|-------------|---------------|
37-
| `latest` | Latest stable release | `linux/amd64`, `linux/arm64` |
38-
| `1.2.3` | Specific version | `linux/amd64`, `linux/arm64` |
39-
| `main` | ⚠️ Development build from main branch | `linux/amd64`, `linux/arm64` |
37+
| `latest` | Latest stable release | `amd64` `arm64` |
38+
| `1.2.3` | Specific version | `amd64` `arm64` |
39+
| `main` | ⚠️ Development build from main branch | `amd64` `arm64` |
4040

4141
**Note:** As of 1.0.3 all images support both AMD64 (Intel/AMD) and ARM64 (Raspberry Pi, Apple Silicon) architectures. Docker will automatically pull the correct architecture for your system.
4242

0 commit comments

Comments
 (0)