Refactor Jellyfin plugin: integrate UI components and implement backend contract.

- Integrated "Recommend Film" button and Rating UI into Jellyfin web interface via MutationObserver.
- Implemented full library sync (metadata + user state) in MovieNightSyncService.
- Updated MovieNightBackendClient to support sync, recommendations, ratings, and viewed status.
- Added proxy endpoints to MovieNightController for frontend-backend communication.
- Refined sync logic to handle large libraries and ensured .NET 9.0 compatibility.

Co-authored-by: devitq <118541411+devitq@users.noreply.github.com>
This commit is contained in:
google-labs-jules[bot]
2026-05-20 17:35:48 +00:00
co-authored by devitq
parent 73b8e4ee02
commit 0a90c3eadf
10 changed files with 344 additions and 11 deletions
@@ -70,11 +70,12 @@ public class MovieNightBackendClient
}
/// <summary>
/// Triggers the current backend Jellyfin sync endpoint.
/// Pushes library sync data to the backend.
/// </summary>
/// <param name="payload">Sync payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response body.</returns>
public async Task<string> TriggerSyncAsync(CancellationToken cancellationToken)
public async Task<string> SyncAsync(object payload, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync");
if (request is null)
@@ -82,12 +83,71 @@ public class MovieNightBackendClient
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;
}
/// <summary>
/// Gets recommendations for a user.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
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();
}
/// <summary>
/// Gets ratings for a user.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
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();
}
/// <summary>
/// Reads backend sync state.
/// </summary>
@@ -1,6 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -11,19 +19,17 @@ namespace Jellyfin.Plugin.MovieNight.Services;
/// </summary>
public sealed class MovieNightPeriodicSyncService : BackgroundService
{
private readonly MovieNightBackendClient _backendClient;
private readonly MovieNightSyncService _syncService;
private readonly ILogger<MovieNightPeriodicSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPeriodicSyncService"/> class.
/// </summary>
/// <param name="backendClient">Backend client.</param>
/// <param name="logger">Logger.</param>
public MovieNightPeriodicSyncService(
MovieNightBackendClient backendClient,
MovieNightSyncService syncService,
ILogger<MovieNightPeriodicSyncService> logger)
{
_backendClient = backendClient;
_syncService = syncService;
_logger = logger;
}
@@ -41,7 +47,7 @@ public sealed class MovieNightPeriodicSyncService : BackgroundService
continue;
}
await _backendClient.TriggerSyncAsync(stoppingToken).ConfigureAwait(false);
await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -0,0 +1,95 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Service for synchronizing the Jellyfin library with MovieNight.
/// </summary>
public class MovieNightSyncService
{
private readonly MovieNightBackendClient _backendClient;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
private readonly ILogger<MovieNightSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightSyncService"/> class.
/// </summary>
public MovieNightSyncService(
MovieNightBackendClient backendClient,
ILibraryManager libraryManager,
IUserManager userManager,
IUserDataManager userDataManager,
ILogger<MovieNightSyncService> logger)
{
_backendClient = backendClient;
_libraryManager = libraryManager;
_userManager = userManager;
_userDataManager = userDataManager;
_logger = logger;
}
/// <summary>
/// Performs a full library sync.
/// </summary>
public async Task PerformSyncAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting MovieNight library sync");
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie },
Recursive = true
});
var users = _userManager.Users;
var syncItems = new List<object>();
foreach (var item in items)
{
if (item is not Movie movie) continue;
var jellyfinItemId = movie.Id.ToString("N");
var itemData = new Dictionary<string, object?>
{
["jellyfinItemId"] = jellyfinItemId,
["title"] = movie.Name,
["originalTitle"] = movie.OriginalTitle,
["description"] = movie.Overview,
["year"] = movie.ProductionYear,
["duration"] = movie.RunTimeTicks,
["genres"] = movie.Genres,
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb),
["userStates"] = users.Select(u => {
var userData = _userDataManager.GetUserData(u, movie);
return new {
jellyfinUserId = u.Id.ToString("N"),
isViewed = userData?.Played ?? false,
playCount = userData?.PlayCount ?? 0,
lastPlayedAt = userData?.LastPlayedDate,
userRating = userData?.Rating
};
}).ToList()
};
syncItems.Add(itemData);
}
await _backendClient.SyncAsync(new { items = syncItems }, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("MovieNight library sync completed");
}
}