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.
/// </summary>
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("#SyncIntervalMinutes").value =
config.SyncIntervalMinutes || 30;
view.querySelector("#StrmOutputPath").value =
config.StrmOutputPath || "";
view.querySelector("#Enabled").checked = config.Enabled || false;
view.querySelector("#EnablePeriodicSync").checked =
config.EnablePeriodicSync !== false;
@@ -39,6 +41,7 @@ const movieNightConfigPage = {
form.querySelector("#SyncIntervalMinutes").value || "30",
10,
);
config.StrmOutputPath = form.querySelector("#StrmOutputPath").value;
config.Enabled = form.querySelector("#Enabled").checked;
config.EnablePeriodicSync =
form.querySelector("#EnablePeriodicSync").checked;
@@ -27,6 +27,12 @@
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
</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">
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
<span>Enable MovieNight integration</span>
@@ -1,20 +1,42 @@
(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 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() {
// 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);
const headerButtons = document.querySelector('.headerViewButtons, .view-library .content-primary, .home-section .sectionTitleContainer');
if (headerButtons) {
if (!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);
}
if (!document.querySelector('.btnMovieNightAddMovie')) {
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);
}
}
// 2. Inject Rating UI in Item Details
const detailButtons = document.querySelector('.itemDetailButtons');
const detailButtons = document.querySelector('.itemDetailButtons, .itemDetailsButtons');
if (detailButtons && !document.querySelector('.movieNightRatingContainer')) {
const itemId = getItemIdFromUrl();
if (itemId) {
@@ -26,10 +48,12 @@
const label = document.createElement('span');
label.innerText = 'MovieNight: ';
label.style.marginRight = '0.5em';
container.appendChild(label);
const select = document.createElement('select');
select.className = 'emby-select';
select.style.padding = '0.2em';
for (let i = 0; i <= 10; i++) {
const opt = document.createElement('option');
opt.value = i;
@@ -46,37 +70,46 @@
function getItemIdFromUrl() {
const params = new URLSearchParams(window.location.search);
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;
return params.get('id') || params.get('itemId');
}
async function showRecommendation() {
const userId = ApiClient.getCurrentUserId();
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;
if (recommendations && recommendations.length > 0) {
const rec = recommendations[0];
const film = rec.film || rec;
Dashboard.alert({
showMsg({
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.');
showMsg('No recommendations found at the moment.');
}
} catch (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 {
await ApiClient.ajax({
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' }),
contentType: 'application/json'
});
Dashboard.alert('Rating submitted!');
showMsg('Rating submitted!');
} catch (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(() => {
if (pendingInjection) {
return;
}
pendingInjection = true;
requestAnimationFrame(() => {
pendingInjection = false;
injectUI();
});
});
const observer = new MutationObserver(injectUI);
observer.observe(document.body, { childList: true, subtree: true });
// Initial call
injectUI();
})();
@@ -1,5 +1,5 @@
using System;
using System.Security.Claims;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.MovieNight.Services;
@@ -89,12 +89,7 @@ public class MovieNightController : ControllerBase
[FromQuery] int limit = 10,
CancellationToken cancellationToken = default)
{
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult))
{
return errorResult!;
}
return await _backendClient.GetRecommendationsAsync(authenticatedUserId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
}
/// <summary>
@@ -107,12 +102,7 @@ public class MovieNightController : ControllerBase
[FromBody] RatingRequest request,
CancellationToken cancellationToken)
{
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult))
{
return errorResult!;
}
await _backendClient.PostRatingAsync(authenticatedUserId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
return Ok();
}
@@ -126,44 +116,51 @@ public class MovieNightController : ControllerBase
[FromBody] ViewedRequest request,
CancellationToken cancellationToken)
{
if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult))
{
return errorResult!;
}
await _backendClient.MarkViewedAsync(authenticatedUserId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
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(
HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ??
HttpContext.User.FindFirstValue("JellyfinUserId") ??
HttpContext.User.FindFirstValue("UserId"));
if (string.IsNullOrWhiteSpace(authenticatedUserId))
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath))
{
errorResult = Unauthorized();
return false;
return BadRequest("STRM output path is not configured.");
}
var normalizedRouteUserId = NormalizeId(routeUserId);
if (!string.Equals(authenticatedUserId, normalizedRouteUserId, StringComparison.OrdinalIgnoreCase))
try
{
errorResult = Forbid();
return false;
if (!Directory.Exists(config.StrmOutputPath))
{
Directory.CreateDirectory(config.StrmOutputPath);
}
var safeTitle = string.Join("_", request.Title.Split(Path.GetInvalidFileNameChars()));
var fileName = $"{safeTitle}.strm";
var filePath = Path.Combine(config.StrmOutputPath, fileName);
// 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 StatusCode(500, $"Failed to create film: {ex.Message}");
}
errorResult = null;
return true;
}
private static string NormalizeId(string? value)
{
return Guid.TryParse(value, out var parsedGuid) ? parsedGuid.ToString("N") : string.Empty;
}
}
/// <summary>
/// Create film request.
/// </summary>
public sealed record CreateFilmRequest(string Title);
/// <summary>
/// Rating request.
/// </summary>
1
@@ -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;
@@ -48,45 +48,29 @@ public class MovieNightSyncService
{
_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 },
Recursive = true
});
};
if (enabledLibraryIds.Count > 0)
{
query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray();
}
var items = _libraryManager.GetItemList(query);
var users = _userManager.Users;
var syncItems = new List<object>();
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
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 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?>
{
@@ -97,9 +81,19 @@ public class MovieNightSyncService
["year"] = movie.ProductionYear,
["duration"] = movie.RunTimeTicks,
["genres"] = movie.Genres,
["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary",
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
["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);
1