Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Api/SeerrRequests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,24 @@ public class ValidateSeerrSessionRequest : IReturn<object> { }
[Authenticated]
public class SeerrLogoutRequest : IReturn<object> { }

[Route("/Moonfin/Seerr/Radarr/Calendar", "GET")]
[Route("/Moonfin/Jellyseerr/Radarr/Calendar", "GET")]
[Authenticated]
public class GetRadarrCalendarRequest : IReturn<object>
{
public string? Start { get; set; }
public string? End { get; set; }
}

[Route("/Moonfin/Seerr/Sonarr/Calendar", "GET")]
[Route("/Moonfin/Jellyseerr/Sonarr/Calendar", "GET")]
[Authenticated]
public class GetSonarrCalendarRequest : IReturn<object>
{
public string? Start { get; set; }
public string? End { get; set; }
}

[Route("/Moonfin/Seerr/Api/{Path*}", "GET")]
[Route("/Moonfin/Jellyseerr/Api/{Path*}", "GET")]
[Authenticated]
Expand Down
21 changes: 21 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Api/SeerrService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,27 @@ public SeerrService(IApplicationHost appHost)
public async Task<object?> Put(SeerrProxyPutRequest request) => await ProxyApiRequest(HttpMethod.Put, request.Path, request.RequestStream).ConfigureAwait(false);
public async Task<object?> Delete(SeerrProxyDeleteRequest request) => await ProxyApiRequest(HttpMethod.Delete, request.Path, null).ConfigureAwait(false);

public Task<object?> Get(GetRadarrCalendarRequest request) => GetArrCalendar("radarr", request.Start, request.End);
public Task<object?> Get(GetSonarrCalendarRequest request) => GetArrCalendar("sonarr", request.Start, request.End);

private async Task<object?> GetArrCalendar(string service, string? start, string? end)
{
var config = Plugin.Instance?.Configuration;
if (config?.SeerrEnabled != true || string.IsNullOrEmpty(config.GetEffectiveSeerrUrl()))
{ Request.Response.StatusCode = 503; return new { error = "Seerr integration is not enabled" }; }

if (AuthHelpers.GetCurrentUserId(Request, _authContext) == null)
{ Request.Response.StatusCode = 401; return new { error = "User not authenticated" }; }

var result = await Session.GetArrCalendarAsync(service, start, end).ConfigureAwait(false);

Request.Response.StatusCode = result.StatusCode;
if (result.Body == null) return null;

var responseContentType = string.IsNullOrWhiteSpace(result.ContentType) ? "application/octet-stream" : result.ContentType;
return ResultFactory.GetResult(Request, new MemoryStream(result.Body), responseContentType, null);
}

private async Task<object?> ProxyApiRequest(HttpMethod method, string? path, System.IO.Stream? bodyStream)
{
var config = Plugin.Instance?.Configuration;
Expand Down
94 changes: 94 additions & 0 deletions Emby/Emby.Plugins.Moonfin/Services/SeerrSessionService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
Expand Down Expand Up @@ -520,6 +521,99 @@ private static SeerrProxyResponse ErrorResponse(int status, string error, string
}

/// <summary>Enumerates all stored Seerr sessions.</summary>
// Seerr owner is user 1, and permissions bit 2 is the admin flag.
private const int OwnerSeerrUserId = 1;
private const int AdminBit = 2;

/// <summary>
/// Fetches the Radarr or Sonarr upcoming calendar server-side so the API key never reaches the
/// client. The arr connection is read from an admin Seerr session and the arr is called directly,
/// which also lets remote clients see the calendar when the arr host is only on the local network.
/// </summary>
public async Task<SeerrProxyResponse> GetArrCalendarAsync(string service, string? start, string? end)
{
var isSonarr = string.Equals(service, "sonarr", StringComparison.OrdinalIgnoreCase);
var settingsPath = isSonarr ? "settings/sonarr" : "settings/radarr";

var adminSession = EnumerateSessions().FirstOrDefault(
s => s.SeerrUserId == OwnerSeerrUserId || (s.Permissions & AdminBit) != 0);
if (adminSession == null)
{
return CalendarError(503, "No admin Seerr session is available to read the arr settings");
}

var settings = await ProxyRequestAsync(adminSession.JellyfinUserId, HttpMethod.Get, settingsPath).ConfigureAwait(false);
if (settings.Body == null || settings.StatusCode != 200)
{
return settings;
}

JsonElement server;
try
{
using var doc = JsonDocument.Parse(settings.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0)
{
return CalendarError(404, "No " + service + " server is configured in Seerr");
}
server = doc.RootElement[0].Clone();
}
catch (JsonException)
{
return CalendarError(502, "Could not read the arr settings from Seerr");
}

var hostname = server.TryGetProperty("hostname", out var h) ? h.GetString() ?? string.Empty : string.Empty;
if (hostname.Length == 0)
{
return CalendarError(404, "No " + service + " host is configured in Seerr");
}

var port = server.TryGetProperty("port", out var p) && p.TryGetInt32(out var portValue)
? portValue
: (isSonarr ? 8989 : 7878);
var useSsl = server.TryGetProperty("useSsl", out var ssl) && ssl.ValueKind == JsonValueKind.True;
var baseUrl = (server.TryGetProperty("baseUrl", out var b) ? b.GetString() : null)?.TrimEnd('/') ?? string.Empty;
var apiKey = server.TryGetProperty("apiKey", out var k) ? k.GetString() ?? string.Empty : string.Empty;

var protocol = useSsl ? "https" : "http";
var startDate = string.IsNullOrWhiteSpace(start) ? DateTime.UtcNow.ToString("yyyy-MM-dd") : start;
var endDate = string.IsNullOrWhiteSpace(end) ? DateTime.UtcNow.AddDays(90).ToString("yyyy-MM-dd") : end;
var query = "start=" + Uri.EscapeDataString(startDate) + "&end=" + Uri.EscapeDataString(endDate);
if (isSonarr)
{
query += "&includeSeries=true";
}
var url = protocol + "://" + hostname + ":" + port + baseUrl + "/api/v3/calendar?" + query;

try
{
using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(15) };
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Api-Key", apiKey);
using var arrResponse = await client.SendAsync(request).ConfigureAwait(false);
var arrBody = await arrResponse.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return new SeerrProxyResponse
{
StatusCode = (int)arrResponse.StatusCode,
Body = arrBody,
ContentType = arrResponse.Content.Headers.ContentType?.ToString() ?? "application/json"
};
}
catch (Exception ex)
{
_logger.Error("Failed to fetch " + service + " calendar: " + ex.Message);
return CalendarError(502, "Could not reach the " + service + " server");
}
}

private static SeerrProxyResponse CalendarError(int statusCode, string message) => new SeerrProxyResponse
{
StatusCode = statusCode,
Body = JsonSerializer.SerializeToUtf8Bytes(new { error = message }),
ContentType = "application/json"
};

public IEnumerable<SeerrSession> EnumerateSessions()
{
if (!Directory.Exists(_sessionsPath)) yield break;
Expand Down
50 changes: 50 additions & 0 deletions Jellyfin/backend/Api/SeerrProxyController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,56 @@ public async Task<IActionResult> ProxyDelete(string path)
return await ProxyApiRequest(HttpMethod.Delete, path);
}

/// <summary>
/// The upcoming Radarr calendar, fetched server-side so the API key stays on the server and a
/// remote client can still see the calendar when Radarr is only reachable on the local network.
/// </summary>
[HttpGet("Radarr/Calendar")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<IActionResult> GetRadarrCalendar() => GetArrCalendar("radarr");

/// <summary>
/// The upcoming Sonarr calendar, fetched server-side so the API key stays on the server and a
/// remote client can still see the calendar when Sonarr is only reachable on the local network.
/// </summary>
[HttpGet("Sonarr/Calendar")]
[Authorize]
[ProducesResponseType(StatusCodes.Status200OK)]
public Task<IActionResult> GetSonarrCalendar() => GetArrCalendar("sonarr");

private async Task<IActionResult> GetArrCalendar(string service)
{
var config = MoonfinPlugin.Instance?.Configuration;
var seerrUrl = config?.GetEffectiveSeerrUrl();
if (config?.SeerrEnabled != true || string.IsNullOrEmpty(seerrUrl))
{
return StatusCode(StatusCodes.Status503ServiceUnavailable,
new { error = "Seerr integration is not enabled" });
}

var userId = this.GetUserIdFromClaims();
if (userId == null)
{
return Unauthorized(new { error = "User not authenticated" });
}

var start = Request.Query["start"].ToString();
var end = Request.Query["end"].ToString();
var result = await _sessionService.GetArrCalendarAsync(service, start, end);
if (result.Body == null)
{
return StatusCode(result.StatusCode);
}

var responseContentType = string.IsNullOrWhiteSpace(result.ContentType)
? MediaTypeNames.Application.Octet
: result.ContentType;

Response.StatusCode = result.StatusCode;
return File(result.Body, responseContentType);
}

private async Task<IActionResult> ProxyApiRequest(HttpMethod method, string path)
{
var config = MoonfinPlugin.Instance?.Configuration;
Expand Down
94 changes: 94 additions & 0 deletions Jellyfin/backend/Services/SeerrSessionService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,100 @@ public async Task<SeerrProxyResponse> ProxyRequestAsync(
}
}

// Seerr owner is user 1, and permissions bit 2 is the admin flag.
private const int OwnerSeerrUserId = 1;
private const int AdminBit = 2;

/// <summary>
/// Fetches the Radarr or Sonarr upcoming calendar server-side so the API key never reaches the
/// client. The arr connection is read from an admin Seerr session and the arr is called directly,
/// which also lets remote clients see the calendar when the arr host is only on the local network.
/// </summary>
public async Task<SeerrProxyResponse> GetArrCalendarAsync(string service, string? start, string? end)
{
var isSonarr = string.Equals(service, "sonarr", StringComparison.OrdinalIgnoreCase);
var settingsPath = isSonarr ? "settings/sonarr" : "settings/radarr";

var adminSession = EnumerateSessions().FirstOrDefault(
s => s.SeerrUserId == OwnerSeerrUserId || (s.Permissions & AdminBit) != 0);
if (adminSession == null)
{
return CalendarError(503, "No admin Seerr session is available to read the arr settings");
}

var settings = await ProxyRequestAsync(adminSession.JellyfinUserId, HttpMethod.Get, settingsPath);
if (settings.Body == null || settings.StatusCode != 200)
{
return settings;
}

JsonElement server;
try
{
using var doc = JsonDocument.Parse(settings.Body);
if (doc.RootElement.ValueKind != JsonValueKind.Array || doc.RootElement.GetArrayLength() == 0)
{
return CalendarError(404, $"No {service} server is configured in Seerr");
}
server = doc.RootElement[0].Clone();
}
catch (JsonException)
{
return CalendarError(502, "Could not read the arr settings from Seerr");
}

var hostname = server.TryGetProperty("hostname", out var h) ? h.GetString() ?? string.Empty : string.Empty;
if (hostname.Length == 0)
{
return CalendarError(404, $"No {service} host is configured in Seerr");
}

var port = server.TryGetProperty("port", out var p) && p.TryGetInt32(out var portValue)
? portValue
: (isSonarr ? 8989 : 7878);
var useSsl = server.TryGetProperty("useSsl", out var ssl) && ssl.ValueKind == JsonValueKind.True;
var baseUrl = (server.TryGetProperty("baseUrl", out var b) ? b.GetString() : null)?.TrimEnd('/') ?? string.Empty;
var apiKey = server.TryGetProperty("apiKey", out var k) ? k.GetString() ?? string.Empty : string.Empty;

var protocol = useSsl ? "https" : "http";
var startDate = string.IsNullOrWhiteSpace(start) ? DateTime.UtcNow.ToString("yyyy-MM-dd") : start;
var endDate = string.IsNullOrWhiteSpace(end) ? DateTime.UtcNow.AddDays(90).ToString("yyyy-MM-dd") : end;
var query = $"start={Uri.EscapeDataString(startDate)}&end={Uri.EscapeDataString(endDate)}";
if (isSonarr)
{
query += "&includeSeries=true";
}
var url = $"{protocol}://{hostname}:{port}{baseUrl}/api/v3/calendar?{query}";

try
{
using var client = _httpClientFactory.CreateClient();
client.Timeout = TimeSpan.FromSeconds(15);
using var request = new HttpRequestMessage(HttpMethod.Get, url);
request.Headers.Add("X-Api-Key", apiKey);
using var arrResponse = await client.SendAsync(request);
var arrBody = await arrResponse.Content.ReadAsByteArrayAsync();
return new SeerrProxyResponse
{
StatusCode = (int)arrResponse.StatusCode,
Body = arrBody,
ContentType = arrResponse.Content.Headers.ContentType?.ToString() ?? "application/json"
};
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch {Service} calendar", service);
return CalendarError(502, $"Could not reach the {service} server");
}
}

private static SeerrProxyResponse CalendarError(int statusCode, string message) => new SeerrProxyResponse
{
StatusCode = statusCode,
Body = JsonSerializer.SerializeToUtf8Bytes(new { error = message }),
ContentType = "application/json"
};

/// <summary>
/// Enumerates all stored Seerr sessions.
/// </summary>
Expand Down
Loading