feat: Jellyfin Plugin UI Integration #51
@@ -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 });
|
||||
|
||||
|
|
||||
// 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,14 +1,6 @@
|
||||
using System;
|
||||
|
This file includes several unused 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();
|
||||
|
||||
|
`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.
Implemented in 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
|
||||
};
|
||||
|
||||
|
The sync payload builds 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);
|
||||
|
||||
itemIdis 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 usingToString("N")(no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URLidto the same canonical format before calling the API (and applyingencodeURIComponentwhen interpolating path segments).