feat: Jellyfin Plugin UI Integration #51

Merged
devitq merged 13 commits from feature/jellyfin-plugin-ui-integration-2323592149917875874 into feat/implement-jellyfin-plugin-46 2026-05-22 13:36:25 +00:00
10 changed files with 344 additions and 11 deletions
Showing only changes of commit 0a90c3eadf - Show all commits
@@ -16,6 +16,11 @@ const movieNightConfigPage = {
config.EnablePeriodicSync !== false;
view.querySelector("#EnablePlaybackEvents").checked =
config.EnablePlaybackEvents !== false;
const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", {
name: "MovieNight.ui.js",
});
view.querySelector("#UIScriptUrl").innerText = uiScriptUrl;
})
.finally(() => {
Dashboard.hideLoadingMsg();
@@ -53,6 +53,12 @@
<span>Test connection</span>
</button>
</div>
<div style="margin-top: 2em; padding: 1em; background: #333; border-radius: 4px;">
<h3>UI Integration</h3>
<p>To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin <strong>Custom JavaScript</strong> setting (Dashboard > General):</p>
<code id="UIScriptUrl" style="display: block; padding: 0.5em; background: #000; word-break: break-all;"></code>
</div>
</form>
</div>
</div>
@@ -0,0 +1,96 @@
(function () {
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:38 +00:00 (Migrated from github.com)
Review

The MutationObserver runs injectUI on every DOM mutation across the entire subtree. On dynamic Jellyfin pages this can fire very frequently and repeatedly execute multiple querySelector calls, impacting UI responsiveness. Consider debouncing/throttling injectUI, narrowing the observed subtree, and/or disconnecting the observer once the UI elements have been injected for the current view.

The `MutationObserver` runs `injectUI` on every DOM mutation across the entire subtree. On dynamic Jellyfin pages this can fire very frequently and repeatedly execute multiple `querySelector` calls, impacting UI responsiveness. Consider debouncing/throttling `injectUI`, narrowing the observed subtree, and/or disconnecting the observer once the UI elements have been injected for the current view.
const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
function injectUI() {
// 1. Inject "Recommend me a film" button in Library views
const headerButtons = document.querySelector('.headerViewButtons');
if (headerButtons && !document.querySelector('.btnMovieNightRecommend')) {
const btn = document.createElement('button');
btn.className = 'emby-button raised btnMovieNightRecommend';
btn.innerHTML = '<span>Recommend Film</span>';
btn.style.marginLeft = '1em';
btn.onclick = showRecommendation;
headerButtons.appendChild(btn);
}
// 2. Inject Rating UI in Item Details
const detailButtons = document.querySelector('.itemDetailButtons');
if (detailButtons && !document.querySelector('.movieNightRatingContainer')) {
const itemId = getItemIdFromUrl();
if (itemId) {
const container = document.createElement('div');
container.className = 'movieNightRatingContainer';
container.style.display = 'inline-flex';
container.style.alignItems = 'center';
container.style.marginLeft = '1em';
const label = document.createElement('span');
label.innerText = 'MovieNight: ';
container.appendChild(label);
const select = document.createElement('select');
select.className = 'emby-select';
for (let i = 0; i <= 10; i++) {
const opt = document.createElement('option');
opt.value = i;
opt.innerText = i === 0 ? 'Rate...' : i;
select.appendChild(opt);
}
select.onchange = (e) => submitRating(itemId, e.target.value);
container.appendChild(select);
detailButtons.appendChild(container);
}
}
}
function getItemIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('id');
}
async function showRecommendation() {
const userId = ApiClient.getCurrentUserId();
try {
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
if (recommendations && recommendations.length > 0) {
const rec = recommendations[0];
const film = rec.film || rec;
Dashboard.alert({
title: 'MovieNight Recommendation',
text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}`
});
} else {
Dashboard.alert('No recommendations found at the moment.');
}
} catch (err) {
console.error('Failed to get recommendations', err);
Dashboard.alert('Failed to get recommendations from MovieNight.');
}
}
async function submitRating(itemId, score) {
if (score === "0") return;
const userId = ApiClient.getCurrentUserId();
try {
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
contentType: 'application/json'
});
Dashboard.alert('Rating submitted!');
} catch (err) {
console.error('Failed to submit rating', err);
Dashboard.alert('Failed to submit rating to MovieNight.');
}
}
const observer = new MutationObserver(injectUI);
observer.observe(document.body, { childList: true, subtree: true });
// Initial call
injectUI();
})();
@@ -16,14 +16,15 @@ namespace Jellyfin.Plugin.MovieNight.Controllers;
public class MovieNightController : ControllerBase
{
private readonly MovieNightBackendClient _backendClient;
private readonly MovieNightSyncService _syncService;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
/// </summary>
/// <param name="backendClient">Backend client.</param>
public MovieNightController(MovieNightBackendClient backendClient)
public MovieNightController(MovieNightBackendClient backendClient, MovieNightSyncService syncService)
{
_backendClient = backendClient;
_syncService = syncService;
}
/// <summary>
@@ -61,7 +62,8 @@ public class MovieNightController : ControllerBase
[HttpPost("Sync")]
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
{
return await _backendClient.TriggerSyncAsync(cancellationToken).ConfigureAwait(false);
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
return Ok("Sync triggered");
}
/// <summary>
@@ -74,8 +76,60 @@ public class MovieNightController : ControllerBase
{
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets recommendations for the current user.
/// </summary>
[HttpGet("Users/{userId}/Recommendations")]
public async Task<ActionResult<string>> GetRecommendations(
[FromRoute] string userId,
[FromQuery] string? contentType,
[FromQuery] string? mood,
[FromQuery] int limit = 10,
CancellationToken cancellationToken = default)
{
return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
public async Task<ActionResult> PostRating(
[FromRoute] string userId,
[FromRoute] string filmId,
[FromBody] RatingRequest request,
CancellationToken cancellationToken)
{
await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
public async Task<ActionResult> MarkViewed(
[FromRoute] string userId,
[FromRoute] string filmId,
[FromBody] ViewedRequest request,
CancellationToken cancellationToken)
{
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
return Ok();
}
}
/// <summary>
/// Rating request.
/// </summary>
public sealed record RatingRequest(int Score, string? Note);
/// <summary>
/// Viewed request.
/// </summary>
public sealed record ViewedRequest(DateTimeOffset? WatchedAt);
/// <summary>
/// MovieNight plugin status response.
/// </summary>
@@ -27,8 +27,10 @@
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<None Remove="Configuration\config.js" />
<None Remove="Configuration\ui.js" />
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\config.js" />
<EmbeddedResource Include="Configuration\ui.js" />
</ItemGroup>
</Project>
@@ -59,6 +59,14 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
CultureInfo.InvariantCulture,
"{0}.Configuration.config.js",
GetType().Namespace)
},
new PluginPageInfo
{
Name = Name + ".ui.js",
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.ui.js",
GetType().Namespace)
}
];
}
@@ -14,6 +14,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<MovieNightBackendClient>();
serviceCollection.AddSingleton<MovieNightSyncService>();
serviceCollection.AddHostedService<MovieNightPeriodicSyncService>();
serviceCollection.AddHostedService<MovieNightPlaybackEventService>();
}
@@ -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;
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:38 +00:00 (Migrated from github.com)
Review

This file includes several unused using directives (e.g., System.Collections.Generic, System.Linq, and the MediaBrowser.* / Jellyfin.Data.Enums imports). Please remove unused usings to reduce noise and avoid analyzer warnings.

This file includes several unused `using` directives (e.g., `System.Collections.Generic`, `System.Linq`, and the `MediaBrowser.*` / `Jellyfin.Data.Enums` imports). Please remove unused usings to reduce noise and avoid analyzer warnings.
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,
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:36 +00:00 (Migrated from github.com)
Review

PerformSyncAsync can run for a long time (full library walk + per-user lookups) but the loop never checks cancellationToken. This can delay shutdown/cancellation even after a stop request. Consider calling cancellationToken.ThrowIfCancellationRequested() (or equivalent) inside the foreach and before expensive work.

`PerformSyncAsync` can run for a long time (full library walk + per-user lookups) but the loop never checks `cancellationToken`. This can delay shutdown/cancellation even after a stop request. Consider calling `cancellationToken.ThrowIfCancellationRequested()` (or equivalent) inside the `foreach` and before expensive work.
copilot-swe-agent[bot] commented 2026-05-20 18:53:26 +00:00 (Migrated from github.com)
Review

Implemented in 07c9995: added an early cancellationToken.ThrowIfCancellationRequested() at the start of PerformSyncAsync so cancellation is honored before the full library query begins, in addition to the existing checks inside the loops.

Implemented in 07c9995: added an early `cancellationToken.ThrowIfCancellationRequested()` at the start of `PerformSyncAsync` so cancellation is honored before the full library query begins, in addition to the existing checks inside the loops.
["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");
}
}