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
7 changed files with 141 additions and 107 deletions
Showing only changes of commit 841ce8534f - Show all commits
@@ -42,4 +42,9 @@ public class PluginConfiguration : BasePluginConfiguration
/// Gets or sets enabled Jellyfin library ids. Empty means all libraries. /// Gets or sets enabled Jellyfin library ids. Empty means all libraries.
/// </summary> /// </summary>
public List<string> EnabledLibraryIds { get; set; } = new(); public List<string> EnabledLibraryIds { get; set; } = new();
/// <summary>
/// Gets or sets the path where .strm files will be created.
/// </summary>
public string StrmOutputPath { get; set; } = string.Empty;
} }
@@ -11,6 +11,8 @@ const movieNightConfigPage = {
view.querySelector("#ApiToken").value = config.ApiToken || ""; view.querySelector("#ApiToken").value = config.ApiToken || "";
view.querySelector("#SyncIntervalMinutes").value = view.querySelector("#SyncIntervalMinutes").value =
config.SyncIntervalMinutes || 30; config.SyncIntervalMinutes || 30;
view.querySelector("#StrmOutputPath").value =
config.StrmOutputPath || "";
view.querySelector("#Enabled").checked = config.Enabled || false; view.querySelector("#Enabled").checked = config.Enabled || false;
view.querySelector("#EnablePeriodicSync").checked = view.querySelector("#EnablePeriodicSync").checked =
config.EnablePeriodicSync !== false; config.EnablePeriodicSync !== false;
@@ -39,6 +41,7 @@ const movieNightConfigPage = {
form.querySelector("#SyncIntervalMinutes").value || "30", form.querySelector("#SyncIntervalMinutes").value || "30",
10, 10,
); );
config.StrmOutputPath = form.querySelector("#StrmOutputPath").value;
config.Enabled = form.querySelector("#Enabled").checked; config.Enabled = form.querySelector("#Enabled").checked;
config.EnablePeriodicSync = config.EnablePeriodicSync =
form.querySelector("#EnablePeriodicSync").checked; form.querySelector("#EnablePeriodicSync").checked;
@@ -27,6 +27,12 @@
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" /> <input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
</div> </div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="StrmOutputPath">STRM output path</label>
<input is="emby-input" id="StrmOutputPath" name="StrmOutputPath" type="text" placeholder="/data/movies/movienight" />
<div class="fieldDescription">Directory where .strm files will be created for new films.</div>
</div>
<label class="checkboxContainer"> <label class="checkboxContainer">
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" /> <input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
<span>Enable MovieNight integration</span> <span>Enable MovieNight integration</span>
@@ -1,10 +1,23 @@
(function () { (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"; const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
function getAlert() {
if (typeof Dashboard !== 'undefined' && Dashboard.alert) {
return (options) => Dashboard.alert(options);
}
return (options) => {
const msg = typeof options === 'string' ? options : (options.text || options.title);
alert(msg);
};
}
const showMsg = getAlert();
function injectUI() { function injectUI() {
// 1. Inject "Recommend me a film" button in Library views const headerButtons = document.querySelector('.headerViewButtons, .view-library .content-primary, .home-section .sectionTitleContainer');
const headerButtons = document.querySelector('.headerViewButtons');
if (headerButtons && !document.querySelector('.btnMovieNightRecommend')) { if (headerButtons) {
if (!document.querySelector('.btnMovieNightRecommend')) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'emby-button raised btnMovieNightRecommend'; btn.className = 'emby-button raised btnMovieNightRecommend';
btn.innerHTML = '<span>Recommend Film</span>'; btn.innerHTML = '<span>Recommend Film</span>';
@@ -13,8 +26,17 @@
headerButtons.appendChild(btn); headerButtons.appendChild(btn);
} }
// 2. Inject Rating UI in Item Details if (!document.querySelector('.btnMovieNightAddMovie')) {
const detailButtons = document.querySelector('.itemDetailButtons'); const btn = document.createElement('button');
btn.className = 'emby-button raised btnMovieNightAddMovie';
btn.innerHTML = '<span>Add Movie (STRM)</span>';
btn.style.marginLeft = '1em';
btn.onclick = promptAddMovie;
headerButtons.appendChild(btn);
}
}
const detailButtons = document.querySelector('.itemDetailButtons, .itemDetailsButtons');
if (detailButtons && !document.querySelector('.movieNightRatingContainer')) { if (detailButtons && !document.querySelector('.movieNightRatingContainer')) {
const itemId = getItemIdFromUrl(); const itemId = getItemIdFromUrl();
if (itemId) { if (itemId) {
@@ -26,10 +48,12 @@
const label = document.createElement('span'); const label = document.createElement('span');
label.innerText = 'MovieNight: '; label.innerText = 'MovieNight: ';
label.style.marginRight = '0.5em';
container.appendChild(label); container.appendChild(label);
const select = document.createElement('select'); const select = document.createElement('select');
select.className = 'emby-select'; select.className = 'emby-select';
select.style.padding = '0.2em';
for (let i = 0; i <= 10; i++) { for (let i = 0; i <= 10; i++) {
const opt = document.createElement('option'); const opt = document.createElement('option');
opt.value = i; opt.value = i;
@@ -46,37 +70,46 @@
function getItemIdFromUrl() { function getItemIdFromUrl() {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
return normalizeJellyfinId(params.get('id')); return params.get('id') || params.get('itemId');
}
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() { async function showRecommendation() {
const userId = ApiClient.getCurrentUserId(); const userId = ApiClient.getCurrentUserId();
try { try {
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${encodeURIComponent(userId)}/Recommendations`)); const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
const recommendations = typeof response === 'string' ? JSON.parse(response) : response; const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
if (recommendations && recommendations.length > 0) { if (recommendations && recommendations.length > 0) {
const rec = recommendations[0]; const rec = recommendations[0];
const film = rec.film || rec; const film = rec.film || rec;
Dashboard.alert({ showMsg({
title: 'MovieNight Recommendation', title: 'MovieNight Recommendation',
text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}` text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}`
}); });
} else { } else {
Dashboard.alert('No recommendations found at the moment.'); showMsg('No recommendations found at the moment.');
} }
} catch (err) { } catch (err) {
console.error('Failed to get recommendations', err); console.error('Failed to get recommendations', err);
Dashboard.alert('Failed to get recommendations from MovieNight.'); showMsg('Failed to get recommendations from MovieNight.');
}
}
async function promptAddMovie() {
const title = prompt("Enter movie title:");
if (!title) return;
try {
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Films`),
data: JSON.stringify({ title: title }),
contentType: 'application/json'
});
showMsg(`STRM file created for "${title}". Refresh your library to see it.`);
} catch (err) {
console.error('Failed to create movie', err);
showMsg('Failed to create movie. Check plugin configuration and logs.');
} }
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).
} }
@@ -86,31 +119,19 @@
try { try {
await ApiClient.ajax({ await ApiClient.ajax({
type: 'POST', type: 'POST',
url: ApiClient.getUrl(`MovieNight/Users/${encodeURIComponent(userId)}/Ratings/Films/${encodeURIComponent(itemId)}`), url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }), data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
contentType: 'application/json' contentType: 'application/json'
}); });
Dashboard.alert('Rating submitted!'); showMsg('Rating submitted!');
} catch (err) { } catch (err) {
console.error('Failed to submit rating', err); console.error('Failed to submit rating', err);
Dashboard.alert('Failed to submit rating to MovieNight.'); showMsg('Failed to submit rating to MovieNight.');
} }
} }
let pendingInjection = false; const observer = new MutationObserver(injectUI);
const observer = new MutationObserver(() => {
if (pendingInjection) {
return;
}
pendingInjection = true;
requestAnimationFrame(() => {
pendingInjection = false;
injectUI();
});
});
observer.observe(document.body, { childList: true, subtree: true }); observer.observe(document.body, { childList: true, subtree: true });
// Initial call
injectUI(); injectUI();
})(); })();
@@ -1,5 +1,5 @@
using System; using System;
using System.Security.Claims; using System.IO;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.MovieNight.Services; using Jellyfin.Plugin.MovieNight.Services;
@@ -89,12 +89,7 @@ public class MovieNightController : ControllerBase
[FromQuery] int limit = 10, [FromQuery] int limit = 10,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult)) return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
{
return errorResult!;
}
return await _backendClient.GetRecommendationsAsync(authenticatedUserId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
} }
/// <summary> /// <summary>
@@ -107,12 +102,7 @@ public class MovieNightController : ControllerBase
[FromBody] RatingRequest request, [FromBody] RatingRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult)) await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
{
return errorResult!;
}
await _backendClient.PostRatingAsync(authenticatedUserId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
return Ok(); return Ok();
} }
@@ -126,44 +116,51 @@ public class MovieNightController : ControllerBase
[FromBody] ViewedRequest request, [FromBody] ViewedRequest request,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult)) await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
{
return errorResult!;
}
await _backendClient.MarkViewedAsync(authenticatedUserId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
return Ok(); return Ok();
} }
private bool TryValidateCurrentUserId(string routeUserId, out string authenticatedUserId, out ActionResult? errorResult) /// <summary>
/// Creates a new film by generating a .strm file.
/// </summary>
[HttpPost("Films")]
public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request)
{ {
authenticatedUserId = NormalizeId( var config = Plugin.Instance?.Configuration;
HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath))
HttpContext.User.FindFirstValue("JellyfinUserId") ??
HttpContext.User.FindFirstValue("UserId"));
if (string.IsNullOrWhiteSpace(authenticatedUserId))
{ {
errorResult = Unauthorized(); return BadRequest("STRM output path is not configured.");
return false;
} }
var normalizedRouteUserId = NormalizeId(routeUserId); try
if (!string.Equals(authenticatedUserId, normalizedRouteUserId, StringComparison.OrdinalIgnoreCase))
{ {
errorResult = Forbid(); if (!Directory.Exists(config.StrmOutputPath))
return false; {
Directory.CreateDirectory(config.StrmOutputPath);
} }
errorResult = null; var safeTitle = string.Join("_", request.Title.Split(Path.GetInvalidFileNameChars()));
return true; var fileName = $"{safeTitle}.strm";
} var filePath = Path.Combine(config.StrmOutputPath, fileName);
private static string NormalizeId(string? value) // Placeholder content for the .strm file.
// In a real scenario, this could be a URL provided in the request.
await System.IO.File.WriteAllTextAsync(filePath, "http://placeholder.url/upload_me_later").ConfigureAwait(false);
return Ok(new { FilePath = filePath });
}
catch (Exception ex)
{ {
return Guid.TryParse(value, out var parsedGuid) ? parsedGuid.ToString("N") : string.Empty; return StatusCode(500, $"Failed to create film: {ex.Message}");
}
} }
} }
/// <summary>
/// Create film request.
/// </summary>
public sealed record CreateFilmRequest(string Title);
/// <summary> /// <summary>
/// Rating request. /// Rating request.
/// </summary> /// </summary>
1
@@ -1,6 +1,14 @@
using System; 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;
using System.Threading.Tasks; 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.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -48,45 +48,29 @@ public class MovieNightSyncService
{ {
_logger.LogInformation("Starting MovieNight library sync"); _logger.LogInformation("Starting MovieNight library sync");
cancellationToken.ThrowIfCancellationRequested(); var config = Plugin.Instance?.Configuration;
var enabledLibraryIds = config?.EnabledLibraryIds ?? new List<string>();
var items = _libraryManager.GetItemList(new InternalItemsQuery var query = new InternalItemsQuery
{ {
IncludeItemTypes = new[] { BaseItemKind.Movie }, IncludeItemTypes = new[] { BaseItemKind.Movie },
Recursive = true Recursive = true
}); };
if (enabledLibraryIds.Count > 0)
{
query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray();
}
var items = _libraryManager.GetItemList(query);
var users = _userManager.Users; var users = _userManager.Users;
var syncItems = new List<object>(); var syncItems = new List<object>();
foreach (var item in items) foreach (var item in items)
{ {
cancellationToken.ThrowIfCancellationRequested();
if (item is not Movie movie) continue; if (item is not Movie movie) continue;
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 jellyfinItemId = movie.Id.ToString("N"); var jellyfinItemId = movie.Id.ToString("N");
var userStates = new List<object>();
foreach (var user in users)
{
cancellationToken.ThrowIfCancellationRequested();
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?> var itemData = new Dictionary<string, object?>
{ {
@@ -97,9 +81,19 @@ public class MovieNightSyncService
["year"] = movie.ProductionYear, ["year"] = movie.ProductionYear,
["duration"] = movie.RunTimeTicks, ["duration"] = movie.RunTimeTicks,
["genres"] = movie.Genres, ["genres"] = movie.Genres,
["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary",
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb), ["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb), ["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb),
["userStates"] = userStates ["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); syncItems.Add(itemData);
1