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
2 changed files with 42 additions and 59 deletions
Showing only changes of commit 955e503b28 - Show all commits
1
@@ -16,13 +16,11 @@
function createTextButton(text, className, onClick) { function createTextButton(text, className, onClick) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.setAttribute('is', 'emby-button'); btn.is = 'emby-button';
btn.className = `emby-button raised ${className}`; btn.className = `emby-button raised ${className}`;
btn.style.margin = '0.5em'; btn.style.margin = '0.5em';
btn.style.padding = '0.4em 1em'; btn.style.padding = '0.4em 1em';
const span = document.createElement('span'); btn.innerHTML = `<span>${text}</span>`;
span.textContent = text;
btn.appendChild(span);
btn.onclick = onClick; btn.onclick = onClick;
return btn; return btn;
} }
@@ -30,18 +28,14 @@
function createIconButton(icon, title, className, onClick) { function createIconButton(icon, title, className, onClick) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button';
btn.setAttribute('is', 'emby-button'); btn.is = 'emby-button';
btn.className = `button-flat detailButton emby-button ${className}`; btn.className = `button-flat detailButton emby-button ${className}`;
btn.title = title; btn.title = title;
btn.setAttribute('aria-label', title); btn.innerHTML = `
const content = document.createElement('div'); <div class="detailButton-content">
content.className = 'detailButton-content'; <span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
const iconSpan = document.createElement('span'); </div>
iconSpan.className = 'material-icons detailButton-icon'; `;
iconSpan.setAttribute('aria-hidden', 'true');
iconSpan.textContent = icon;
content.appendChild(iconSpan);
btn.appendChild(content);
btn.onclick = onClick; btn.onclick = onClick;
return btn; return btn;
} }
@@ -53,14 +47,14 @@
const itemId = getItemIdFromUrl(); const itemId = getItemIdFromUrl();
if (itemId) { if (itemId) {
// MovieNight Rating // MovieNight Rating
if (!detailButtons.querySelector('.btnMovieNightRate')) { if (!document.querySelector('.btnMovieNightRate')) {
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => { const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId); e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
}); });
insertInDetailRow(detailButtons, rateBtn); insertInDetailRow(detailButtons, rateBtn);
} }
// Mark Viewed in MovieNight // Mark Viewed in MovieNight
if (!detailButtons.querySelector('.btnMovieNightMarkViewed')) { if (!document.querySelector('.btnMovieNightMarkViewed')) {
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => { const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
e.preventDefault(); e.stopPropagation(); submitViewed(itemId); e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
}); });
@@ -71,18 +65,14 @@
// 2. Library Pages - Add text buttons to toolbar // 2. Library Pages - Add text buttons to toolbar
const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x'); const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
if (toolBar) { if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
if (!toolBar.querySelector('.btnMovieNightRecommend')) {
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
e.preventDefault(); showRecommendation(); e.preventDefault(); showRecommendation();
})); }));
}
if (!toolBar.querySelector('.btnMovieNightAddMovie')) {
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => { toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
e.preventDefault(); showAddMovieDialog(); e.preventDefault(); showAddMovieDialog();
})); }));
} }
}
// 3. Home Page - Prepend a MovieNight section // 3. Home Page - Prepend a MovieNight section
const homeSections = document.querySelector('.sections.homeSectionsContainer'); const homeSections = document.querySelector('.sections.homeSectionsContainer');
@@ -123,7 +113,7 @@
const overlay = document.createElement('div'); const overlay = document.createElement('div');
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).
overlay.className = 'dialogBackdrop dialogBackdropOpened'; overlay.className = 'dialogBackdrop dialogBackdropOpened';
overlay.style.zIndex = '99998'; overlay.style.zIndex = '99998';
overlay.style.backgroundColor = 'var(--dialog-backdrop, rgba(0,0,0,0.6))'; overlay.style.backgroundColor = 'rgba(0,0,0,0.6)';
overlay.style.position = 'fixed'; overlay.style.position = 'fixed';
overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0'; overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0';
overlay.style.backdropFilter = 'blur(4px)'; overlay.style.backdropFilter = 'blur(4px)';
@@ -139,25 +129,19 @@
dialog.style.zIndex = '99999'; dialog.style.zIndex = '99999';
dialog.style.padding = '2em'; dialog.style.padding = '2em';
dialog.style.minWidth = '320px'; dialog.style.minWidth = '320px';
dialog.style.backgroundColor = 'var(--theme-body-background)'; dialog.style.backgroundColor = '#1a1a1a';
dialog.style.borderRadius = '1em'; dialog.style.borderRadius = '1.5em';
dialog.style.color = 'var(--theme-body-color)'; dialog.style.color = 'white';
dialog.style.boxShadow = '0 10px 25px rgba(0,0,0,0.5)'; dialog.style.boxShadow = '0 10px 25px rgba(0,0,0,0.5)';
dialog.style.border = '1px solid var(--theme-light-btn-border-color, transparent)'; dialog.style.border = '1px solid #333';
dialog.innerHTML = ` dialog.innerHTML = `
<h2 class="dialogTitle">${title}</h2> <h2 style="margin-top:0; text-align:center; font-weight:400;">${title}</h2>
<div class="dialog-content"></div> <div class="dialog-content" style="margin:1.5em 0;"></div>
<div class="dialog-footer"> <div class="dialog-footer" style="display:flex; gap:1em;">
<button is="emby-button" class="emby-button button-flat btnCancel">Cancel</button> <button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white;">Cancel</button>
</div> </div>
`; `;
const content = dialog.querySelector('.dialog-content');
content.style.margin = '1.5em 0';
const footer = dialog.querySelector('.dialog-footer');
footer.style.display = 'flex';
footer.style.gap = '1em';
dialog.querySelector('.btnCancel').style.flex = '1';
return dialog; return dialog;
} }
@@ -165,19 +149,15 @@
const overlay = createOverlay(); const overlay = createOverlay();
const dialog = createDialogBase('Rate on MovieNight'); const dialog = createDialogBase('Rate on MovieNight');
const content = dialog.querySelector('.dialog-content'); const content = dialog.querySelector('.dialog-content');
const grid = document.createElement('div');
grid.className = 'rating-grid'; content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.6em;"></div>`;
grid.style.display = 'grid'; const grid = content.querySelector('.rating-grid');
grid.style.gridTemplateColumns = 'repeat(5, 1fr)';
grid.style.gap = '0.6em';
content.appendChild(grid);
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
for (let i = 1; i <= 10; i++) { for (let i = 1; i <= 10; i++) {
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.type = 'button'; btn.type = 'button'; btn.is = 'emby-button';
btn.setAttribute('is', 'emby-button');
btn.className = 'emby-button raised'; btn.className = 'emby-button raised';
btn.innerText = i; btn.innerText = i;
btn.style.padding = '0.8em 0'; btn.style.padding = '0.8em 0';
@@ -225,7 +205,6 @@
}; };
dialog.querySelector('.btnCancel').onclick = cleanup; dialog.querySelector('.btnCancel').onclick = cleanup;
dialog.querySelector('.btnCancel').style.width = '100%';
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog); overlay.appendChild(dialog);
document.body.appendChild(overlay); document.body.appendChild(overlay);
@@ -3,6 +3,7 @@ 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;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -13,7 +14,6 @@ namespace Jellyfin.Plugin.MovieNight.Controllers;
/// </summary> /// </summary>
[ApiController] [ApiController]
[Route("MovieNight")] [Route("MovieNight")]
[Authorize(Policy = "DefaultAuthorization")]
public class MovieNightController : ControllerBase public class MovieNightController : ControllerBase
{ {
private readonly MovieNightBackendClient _backendClient; private readonly MovieNightBackendClient _backendClient;
@@ -22,7 +22,9 @@ public class MovieNightController : ControllerBase
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="MovieNightController"/> class. /// Initializes a new instance of the <see cref="MovieNightController"/> class.
/// </summary> /// </summary>
public MovieNightController(MovieNightBackendClient backendClient, MovieNightSyncService syncService) public MovieNightController(
MovieNightBackendClient backendClient,
MovieNightSyncService syncService)
{ {
_backendClient = backendClient; _backendClient = backendClient;
_syncService = syncService; _syncService = syncService;
@@ -32,7 +34,6 @@ public class MovieNightController : ControllerBase
/// Ping endpoint for connectivity checks. /// Ping endpoint for connectivity checks.
/// </summary> /// </summary>
[HttpGet("Ping")] [HttpGet("Ping")]
[AllowAnonymous]
public ActionResult Ping() => Ok("Pong"); public ActionResult Ping() => Ok("Pong");
/// <summary> /// <summary>
@@ -40,6 +41,7 @@ public class MovieNightController : ControllerBase
/// </summary> /// </summary>
/// <returns>Status response.</returns> /// <returns>Status response.</returns>
[HttpGet("Status")] [HttpGet("Status")]
[Authorize]
public ActionResult<MovieNightPluginStatus> GetStatus() public ActionResult<MovieNightPluginStatus> GetStatus()
{ {
var configuration = Plugin.Instance?.Configuration; var configuration = Plugin.Instance?.Configuration;
@@ -57,7 +59,7 @@ public class MovieNightController : ControllerBase
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connection result.</returns> /// <returns>Connection result.</returns>
[HttpPost("TestConnection")] [HttpPost("TestConnection")]
[Authorize(Policy = "RequiresAdmin")] [Authorize]
public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken) public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken)
{ {
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
@@ -69,7 +71,7 @@ public class MovieNightController : ControllerBase
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns> /// <returns>Backend response.</returns>
[HttpPost("Sync")] [HttpPost("Sync")]
[Authorize(Policy = "RequiresAdmin")] [Authorize]
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken) public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
{ {
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
@@ -82,7 +84,7 @@ public class MovieNightController : ControllerBase
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns> /// <returns>Backend response.</returns>
[HttpGet("SyncState")] [HttpGet("SyncState")]
[Authorize(Policy = "RequiresAdmin")] [Authorize]
public async Task<ActionResult<string>> SyncState(CancellationToken cancellationToken) public async Task<ActionResult<string>> SyncState(CancellationToken cancellationToken)
{ {
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
@@ -92,6 +94,7 @@ public class MovieNightController : ControllerBase
/// Gets recommendations for the current user. /// Gets recommendations for the current user.
/// </summary> /// </summary>
[HttpGet("Users/{userId}/Recommendations")] [HttpGet("Users/{userId}/Recommendations")]
[Authorize]
public async Task<ActionResult<string>> GetRecommendations( public async Task<ActionResult<string>> GetRecommendations(
[FromRoute] string userId, [FromRoute] string userId,
[FromQuery] string? contentType, [FromQuery] string? contentType,
@@ -106,6 +109,7 @@ public class MovieNightController : ControllerBase
/// Posts a rating for a film. /// Posts a rating for a film.
/// </summary> /// </summary>
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")] [HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
[Authorize]
public async Task<ActionResult> PostRating( public async Task<ActionResult> PostRating(
[FromRoute] string userId, [FromRoute] string userId,
[FromRoute] string filmId, [FromRoute] string filmId,
@@ -120,6 +124,7 @@ public class MovieNightController : ControllerBase
/// Marks a film as viewed. /// Marks a film as viewed.
/// </summary> /// </summary>
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")] [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
[Authorize]
public async Task<ActionResult> MarkViewed( public async Task<ActionResult> MarkViewed(
[FromRoute] string userId, [FromRoute] string userId,
[FromRoute] string filmId, [FromRoute] string filmId,
@@ -134,7 +139,7 @@ public class MovieNightController : ControllerBase
/// Creates a new film by generating a .strm file. /// Creates a new film by generating a .strm file.
/// </summary> /// </summary>
[HttpPost("Films")] [HttpPost("Films")]
[Authorize(Policy = "RequiresAdmin")] [Authorize]
public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request) public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request)
{ {
var config = Plugin.Instance?.Configuration; var config = Plugin.Instance?.Configuration;
@@ -154,7 +159,6 @@ public class MovieNightController : ControllerBase
var fileName = $"{safeTitle}.strm"; var fileName = $"{safeTitle}.strm";
var filePath = Path.Combine(config.StrmOutputPath, fileName); var filePath = Path.Combine(config.StrmOutputPath, fileName);
// Use the provided URL or a placeholder if missing
var strmContent = string.IsNullOrWhiteSpace(request.Url) var strmContent = string.IsNullOrWhiteSpace(request.Url)
? "http://placeholder.url/upload_me_later" ? "http://placeholder.url/upload_me_later"
: request.Url; : request.Url;
1