using System; using System.Collections.Generic; using System.Net.Http; using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.MovieNight.Services; /// /// Thin HTTP client for the MovieNight backend. /// public class MovieNightBackendClient { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private readonly ILogger _logger; private readonly HttpClient _httpClient; /// /// Initializes a new instance of the class. /// /// Logger. public MovieNightBackendClient(ILogger logger) { _logger = logger; _httpClient = new HttpClient { Timeout = TimeSpan.FromSeconds(20) }; } /// /// Calls backend health. /// /// Cancellation token. /// Connection result. public async Task TestConnectionAsync(CancellationToken cancellationToken) { var payload = new MovieNightEventPayload( EventId: $"plugin-test:{Guid.NewGuid():N}", EventType: "playback.stopped", OccurredAt: DateTimeOffset.UtcNow, JellyfinUserId: "movienight-plugin-test-user", ItemId: "movienight-plugin-test-item", PayloadVersion: 1, Payload: new Dictionary { ["source"] = "config-test" }); var request = CreateEventRequest(payload); if (request is null) { return MovieNightConnectionResult.Failed("Plugin is not configured."); } try { using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); return response.IsSuccessStatusCode ? MovieNightConnectionResult.Ok() : MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}."); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { _logger.LogWarning(ex, "MovieNight connection test failed"); return MovieNightConnectionResult.Failed(ex.Message); } } /// /// Pushes library sync data to the backend. /// /// Sync payload. /// Cancellation token. /// Backend response body. public async Task SyncAsync(object payload, CancellationToken cancellationToken) { var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync"); if (request is null) { return "Plugin is not configured."; } request.Content = JsonContent.Create(payload, options: JsonOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return body; } /// /// Gets recommendations for a user. /// public async Task GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken) { var query = $"?limit={limit}"; if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}"; if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}"; var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/recommendations{query}"); if (request is null) return "Plugin is not configured."; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return body; } /// /// Posts a rating for a film. /// public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken) { var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/ratings/films/{filmId}"); if (request is null) return; request.Content = JsonContent.Create(new { score, note }, options: JsonOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } /// /// Gets ratings for a user. /// public async Task GetRatingsAsync(string userId, CancellationToken cancellationToken) { var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/ratings"); if (request is null) return "[]"; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return body; } /// /// Marks a film as viewed. /// public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken) { var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed"); if (request is null) return; request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } /// /// Reads backend sync state. /// /// Cancellation token. /// Backend response body. public async Task GetSyncStateAsync(CancellationToken cancellationToken) { var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state"); if (request is null) { return "Plugin is not configured."; } using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); return body; } /// /// Gets user preferences. /// public async Task GetPreferencesAsync(string userId, CancellationToken cancellationToken) { var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences"); if (request is null) return null; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); return body; } /// /// Completes onboarding for a user. /// public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken) { var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding"); if (request is null) return; request.Content = JsonContent.Create(payload, options: JsonOptions); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); } /// /// Pushes an event payload to the backend event endpoint. /// /// Event payload. /// Cancellation token. /// A task. public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken) { for (var attempt = 1; attempt <= 3; attempt++) { var request = CreateEventRequest(payload); if (request is null) { return; } try { using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.IsSuccessStatusCode) { return; } if ((int)response.StatusCode == 401) { _logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized"); return; } _logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode); } catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) { _logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt); } if (attempt < 3) { await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false); } } } private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload) { var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events"); if (request is null) { return null; } request.Content = JsonContent.Create(payload, options: JsonOptions); return request; } private static string? GetBaseUrl() { var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim(); return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); } private static bool IsEnabled() { var configuration = Plugin.Instance?.Configuration; return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl); } private static HttpRequestMessage? CreateRequest(HttpMethod method, string path) { if (!IsEnabled()) { return null; } var baseUrl = GetBaseUrl(); if (baseUrl is null) { return null; } var request = new HttpRequestMessage(method, new Uri(baseUrl + path)); var token = Plugin.Instance?.Configuration.ApiToken; if (!string.IsNullOrWhiteSpace(token)) { request.Headers.Add("X-MovieNight-Plugin-Token", token); } return request; } } /// /// Backend connection result. /// /// Whether the call succeeded. /// Result message. public sealed record MovieNightConnectionResult(bool Success, string Message) { /// /// Creates a successful result. /// /// Connection result. public static MovieNightConnectionResult Ok() => new(true, "OK"); /// /// Creates a failed result. /// /// Failure message. /// Connection result. public static MovieNightConnectionResult Failed(string message) => new(false, message); }