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
4 changed files with 95 additions and 25 deletions
Showing only changes of commit 4c39020689 - Show all commits
1
@@ -46,13 +46,22 @@
function getItemIdFromUrl() {
const params = new URLSearchParams(window.location.search);
return params.get('id');
return normalizeJellyfinId(params.get('id'));
}
function normalizeJellyfinId(value) {
if (!value) {
return null;
}
const normalized = value.replace(/-/g, '').toLowerCase();
return /^[0-9a-f]{32}$/.test(normalized) ? normalized : null;
}
async function showRecommendation() {
const userId = ApiClient.getCurrentUserId();
try {
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${encodeURIComponent(userId)}/Recommendations`));
const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
if (recommendations && recommendations.length > 0) {
@@ -77,7 +86,7 @@
try {
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
url: ApiClient.getUrl(`MovieNight/Users/${encodeURIComponent(userId)}/Ratings/Films/${encodeURIComponent(itemId)}`),
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
contentType: 'application/json'
});
@@ -88,7 +97,18 @@
}
}
const observer = new MutationObserver(injectUI);
let pendingInjection = false;
const observer = new MutationObserver(() => {
if (pendingInjection) {
return;
}
pendingInjection = true;
requestAnimationFrame(() => {
pendingInjection = false;
injectUI();
});
});
observer.observe(document.body, { childList: true, subtree: true });
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:37 +00:00 (Migrated from github.com)
Review

itemId is taken directly from the page URL and then used as {filmId} in the rating POST path. Elsewhere in the plugin (sync/playback events) Jellyfin ids are serialized using ToString("N") (no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URL id to the same canonical format before calling the API (and applying encodeURIComponent when interpolating path segments).

`itemId` is taken directly from the page URL and then used as `{filmId}` in the rating POST path. Elsewhere in the plugin (sync/playback events) Jellyfin ids are serialized using `ToString("N")` (no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URL `id` to the same canonical format before calling the API (and applying `encodeURIComponent` when interpolating path segments).
// Initial call
@@ -1,4 +1,5 @@
using System;
using System.Security.Claims;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.MovieNight.Services;
@@ -88,7 +89,12 @@ public class MovieNightController : ControllerBase
[FromQuery] int limit = 10,
CancellationToken cancellationToken = default)
{
return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult))
{
return errorResult!;
}
return await _backendClient.GetRecommendationsAsync(authenticatedUserId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -101,7 +107,12 @@ public class MovieNightController : ControllerBase
[FromBody] RatingRequest request,
CancellationToken cancellationToken)
{
await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult))
{
return errorResult!;
}
await _backendClient.PostRatingAsync(authenticatedUserId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
return Ok();
}
@@ -115,9 +126,42 @@ public class MovieNightController : ControllerBase
[FromBody] ViewedRequest request,
CancellationToken cancellationToken)
{
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult))
{
return errorResult!;
}
await _backendClient.MarkViewedAsync(authenticatedUserId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
return Ok();
}
private bool TryValidateCurrentUserId(string routeUserId, out string authenticatedUserId, out ActionResult? errorResult)
{
authenticatedUserId = NormalizeId(
HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ??
HttpContext.User.FindFirstValue("JellyfinUserId") ??
HttpContext.User.FindFirstValue("UserId"));
if (string.IsNullOrWhiteSpace(authenticatedUserId))
{
errorResult = Unauthorized();
return false;
}
var normalizedRouteUserId = NormalizeId(routeUserId);
if (!string.Equals(authenticatedUserId, normalizedRouteUserId, StringComparison.OrdinalIgnoreCase))
{
errorResult = Forbid();
return false;
}
errorResult = null;
return true;
}
private static string NormalizeId(string? value)
{
return Guid.TryParse(value, out var parsedGuid) ? parsedGuid.ToString("N") : string.Empty;
}
}
/// <summary>
1
@@ -1,14 +1,6 @@
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;
@@ -59,9 +59,32 @@ public class MovieNightSyncService
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
if (item is not Movie movie) continue;
var jellyfinItemId = movie.Id.ToString("N");
var userStates = new List<object>();
foreach (var user in users)
{
cancellationToken.ThrowIfCancellationRequested();
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.
var userData = _userDataManager.GetUserData(user, movie);
if (userData is null || (!userData.Played && userData.PlayCount == 0 && userData.LastPlayedDate is null && userData.Rating is null))
{
continue;
}
userStates.Add(new
{
jellyfinUserId = user.Id.ToString("N"),
isViewed = userData.Played,
playCount = userData.PlayCount,
lastPlayedAt = userData.LastPlayedDate,
userRating = userData.Rating
});
}
var itemData = new Dictionary<string, object?>
{
@@ -74,16 +97,7 @@ public class MovieNightSyncService
["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()
["userStates"] = userStates
};
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:37 +00:00 (Migrated from github.com)
Review

The sync payload builds userStates by calling _userDataManager.GetUserData(u, movie) for every (movie × user) pair. This is an O(N*M) pattern and can become very expensive on large libraries / many users (and can also create very large payloads). Consider batching user-data retrieval (if Jellyfin exposes a bulk API) and/or syncing user state per-user instead of embedding all users in every item.

The sync payload builds `userStates` by calling `_userDataManager.GetUserData(u, movie)` for every (movie × user) pair. This is an O(N*M) pattern and can become very expensive on large libraries / many users (and can also create very large payloads). Consider batching user-data retrieval (if Jellyfin exposes a bulk API) and/or syncing user state per-user instead of embedding all users in every item.
syncItems.Add(itemData);