From 60756e376c1c355e4b427d97a18d5f09e544c9fc Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Fri, 22 May 2026 06:49:46 +0000
Subject: [PATCH] Refactor UI components and fix API accessibility for Jellyfin
10.11+
- Updated ui.js with modern Jellyfin selectors and native-styled components.
- Replaced prompt() with custom dialogs for "Add Movie" and "Rating".
- Added "Mark Viewed" and "Sync Library" actions with UI status feedback.
- Fixed 401 Unauthorized errors by using explicit Jellyfin authorization policies.
- Enhanced .strm file creation logic to support optional URLs.
- Improved Home page integration with a dedicated MovieNight section.
Co-authored-by: devitq <118541411+devitq@users.noreply.github.com>
---
.../Configuration/ui.js | 200 +++++++++++++-----
.../Controllers/MovieNightController.cs | 24 ++-
2 files changed, 166 insertions(+), 58 deletions(-)
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
index 0e92b85..33e298f 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
@@ -41,21 +41,24 @@
}
function injectUI() {
- // 1. Item Detail Page - Add icon button for rating
+ // 1. Item Detail Page
const detailButtons = document.querySelector('.mainDetailButtons');
- if (detailButtons && !document.querySelector('.btnMovieNightRate')) {
+ if (detailButtons) {
const itemId = getItemIdFromUrl();
if (itemId) {
- const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
- e.preventDefault();
- e.stopPropagation();
- showRatingDialog(itemId);
- });
- const moreBtn = detailButtons.querySelector('.btnMoreCommands');
- if (moreBtn) {
- detailButtons.insertBefore(rateBtn, moreBtn);
- } else {
- detailButtons.appendChild(rateBtn);
+ // MovieNight Rating
+ if (!document.querySelector('.btnMovieNightRate')) {
+ const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
+ e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
+ });
+ insertInDetailRow(detailButtons, rateBtn);
+ }
+ // Mark Viewed in MovieNight
+ if (!document.querySelector('.btnMovieNightMarkViewed')) {
+ const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
+ e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
+ });
+ insertInDetailRow(detailButtons, viewedBtn);
}
}
}
@@ -64,12 +67,10 @@
const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
- e.preventDefault();
- showRecommendation();
+ e.preventDefault(); showRecommendation();
}));
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
- e.preventDefault();
- promptAddMovie();
+ e.preventDefault(); showAddMovieDialog();
}));
}
@@ -79,74 +80,135 @@
const section = document.createElement('div');
section.className = 'verticalSection movieNightHomeButtons';
section.style.padding = '0 var(--sidePadding)';
- section.innerHTML = '
MovieNight
';
+ section.innerHTML = `
+
+
MovieNight
+
+
+
+ `;
const btnContainer = section.querySelector('.movieNightBtnContainer');
btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
- btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', promptAddMovie));
+ btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog));
+ btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync));
+
homeSections.insertBefore(section, homeSections.firstChild);
+ updateSyncStatus();
}
}
+ function insertInDetailRow(container, btn) {
+ const moreBtn = container.querySelector('.btnMoreCommands');
+ if (moreBtn) container.insertBefore(btn, moreBtn);
+ else container.appendChild(btn);
+ }
+
function getItemIdFromUrl() {
const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search;
const params = new URLSearchParams(queryString);
return params.get('id') || params.get('itemId');
}
- async function showRatingDialog(itemId) {
+ function createOverlay() {
const overlay = document.createElement('div');
overlay.className = 'dialogBackdrop dialogBackdropOpened';
overlay.style.zIndex = '99998';
- overlay.style.backgroundColor = 'rgba(0,0,0,0.5)';
+ overlay.style.backgroundColor = 'rgba(0,0,0,0.6)';
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)';
+ return overlay;
+ }
+ function createDialogBase(title) {
const dialog = document.createElement('div');
dialog.className = 'dialog';
dialog.style.position = 'fixed';
- dialog.style.top = '50%';
- dialog.style.left = '50%';
+ dialog.style.top = '50%'; dialog.style.left = '50%';
dialog.style.transform = 'translate(-50%, -50%)';
dialog.style.zIndex = '99999';
dialog.style.padding = '2em';
- dialog.style.minWidth = '250px';
- dialog.style.backgroundColor = '#222';
- dialog.style.borderRadius = '1em';
+ dialog.style.minWidth = '320px';
+ dialog.style.backgroundColor = '#1a1a1a';
+ dialog.style.borderRadius = '1.5em';
dialog.style.color = 'white';
+ dialog.style.boxShadow = '0 10px 25px rgba(0,0,0,0.5)';
+ dialog.style.border = '1px solid #333';
dialog.innerHTML = `
- Rate on MovieNight
-
-
+ ${title}
+
+
`;
+ return dialog;
+ }
+
+ async function showRatingDialog(itemId) {
+ const overlay = createOverlay();
+ const dialog = createDialogBase('Rate on MovieNight');
+ const content = dialog.querySelector('.dialog-content');
+
+ content.innerHTML = ``;
+ const grid = content.querySelector('.rating-grid');
+
+ const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
- const grid = dialog.querySelector('.rating-grid');
for (let i = 1; i <= 10; i++) {
const btn = document.createElement('button');
- btn.type = 'button';
- btn.is = 'emby-button';
+ btn.type = 'button'; btn.is = 'emby-button';
btn.className = 'emby-button raised';
btn.innerText = i;
- btn.style.padding = '0.5em';
- btn.onclick = async () => {
- cleanup();
- await submitRating(itemId, i);
- };
+ btn.style.padding = '0.8em 0';
+ btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
grid.appendChild(btn);
}
- const cleanup = () => {
- if (overlay.parentNode) document.body.removeChild(overlay);
+ dialog.querySelector('.btnCancel').onclick = cleanup;
+ overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
+ overlay.appendChild(dialog);
+ document.body.appendChild(overlay);
+ }
+
+ async function showAddMovieDialog() {
+ const overlay = createOverlay();
+ const dialog = createDialogBase('Add Movie (STRM)');
+ const content = dialog.querySelector('.dialog-content');
+ const footer = dialog.querySelector('.dialog-footer');
+
+ content.innerHTML = `
+
+
+
+
+
+
+
+
+ `;
+
+ const btnAdd = document.createElement('button');
+ btnAdd.className = 'emby-button raised button-submit';
+ btnAdd.style.flex = '2';
+ btnAdd.innerHTML = 'Add Film';
+ footer.insertBefore(btnAdd, footer.firstChild);
+
+ const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
+
+ btnAdd.onclick = async () => {
+ const title = dialog.querySelector('.txtTitle').value;
+ const url = dialog.querySelector('.txtUrl').value;
+ if (!title) return;
+ cleanup();
+ await addMovie(title, url);
};
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
-
overlay.appendChild(dialog);
document.body.appendChild(overlay);
+ dialog.querySelector('.txtTitle').focus();
}
async function showRecommendation() {
@@ -167,28 +229,46 @@
}
} catch (err) {
console.error('Failed to get recommendations', err);
- showMsg('Failed to get recommendations from MovieNight.');
+ showMsg('Failed to get recommendations. Check your API token and MovieNight status.');
}
}
- async function promptAddMovie() {
- const title = prompt("Enter movie title:");
- if (!title) return;
-
+ async function addMovie(title, url) {
try {
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Films`),
- data: JSON.stringify({ title: title }),
+ data: JSON.stringify({ title, url }),
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.');
+ showMsg('Failed to create movie. Ensure STRM output path is configured.');
}
}
+ async function triggerSync() {
+ try {
+ await ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl(`MovieNight/Sync`) });
+ showMsg('Library sync triggered!');
+ setTimeout(updateSyncStatus, 2000);
+ } catch (err) {
+ showMsg('Failed to trigger sync.');
+ }
+ }
+
+ async function updateSyncStatus() {
+ const statusEl = document.querySelector('.movieNightSyncStatus');
+ if (!statusEl) return;
+ try {
+ const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`));
+ if (state && state.lastSyncAt) {
+ statusEl.innerText = `Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`;
+ }
+ } catch (err) { /* ignore */ }
+ }
+
async function submitRating(itemId, score) {
const userId = ApiClient.getCurrentUserId();
try {
@@ -198,10 +278,24 @@
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
contentType: 'application/json'
});
- showMsg('Rating submitted!');
+ showMsg('Rating submitted to MovieNight!');
} catch (err) {
- console.error('Failed to submit rating', err);
- showMsg('Failed to submit rating to MovieNight.');
+ showMsg('Failed to submit rating.');
+ }
+ }
+
+ async function submitViewed(itemId) {
+ const userId = ApiClient.getCurrentUserId();
+ try {
+ await ApiClient.ajax({
+ type: 'POST',
+ url: ApiClient.getUrl(`MovieNight/Users/${userId}/Library/Films/${itemId}/Viewed`),
+ data: JSON.stringify({ watchedAt: new Date().toISOString() }),
+ contentType: 'application/json'
+ });
+ showMsg('Marked as viewed in MovieNight!');
+ } catch (err) {
+ showMsg('Failed to mark as viewed.');
}
}
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
index b5509cb..7bce1ab 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
@@ -12,8 +12,8 @@ namespace Jellyfin.Plugin.MovieNight.Controllers;
/// Admin endpoints for the MovieNight plugin.
///
[ApiController]
-[Authorize]
[Route("MovieNight")]
+[Authorize(Policy = "DefaultAuthorization")]
public class MovieNightController : ControllerBase
{
private readonly MovieNightBackendClient _backendClient;
@@ -28,6 +28,13 @@ public class MovieNightController : ControllerBase
_syncService = syncService;
}
+ ///
+ /// Ping endpoint for connectivity checks.
+ ///
+ [HttpGet("Ping")]
+ [AllowAnonymous]
+ public ActionResult Ping() => Ok("Pong");
+
///
/// Returns plugin status.
///
@@ -50,6 +57,7 @@ public class MovieNightController : ControllerBase
/// Cancellation token.
/// Connection result.
[HttpPost("TestConnection")]
+ [Authorize(Policy = "RequiresAdmin")]
public async Task> TestConnection(CancellationToken cancellationToken)
{
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
@@ -61,6 +69,7 @@ public class MovieNightController : ControllerBase
/// Cancellation token.
/// Backend response.
[HttpPost("Sync")]
+ [Authorize(Policy = "RequiresAdmin")]
public async Task> Sync(CancellationToken cancellationToken)
{
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
@@ -73,6 +82,7 @@ public class MovieNightController : ControllerBase
/// Cancellation token.
/// Backend response.
[HttpGet("SyncState")]
+ [Authorize(Policy = "RequiresAdmin")]
public async Task> SyncState(CancellationToken cancellationToken)
{
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
@@ -124,6 +134,7 @@ public class MovieNightController : ControllerBase
/// Creates a new film by generating a .strm file.
///
[HttpPost("Films")]
+ [Authorize(Policy = "RequiresAdmin")]
public async Task CreateFilm([FromBody] CreateFilmRequest request)
{
var config = Plugin.Instance?.Configuration;
@@ -143,9 +154,12 @@ public class MovieNightController : ControllerBase
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);
+ // Use the provided URL or a placeholder if missing
+ var strmContent = string.IsNullOrWhiteSpace(request.Url)
+ ? "http://placeholder.url/upload_me_later"
+ : request.Url;
+
+ await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false);
return Ok(new { FilePath = filePath });
}
@@ -159,7 +173,7 @@ public class MovieNightController : ControllerBase
///
/// Create film request.
///
-public sealed record CreateFilmRequest(string Title);
+public sealed record CreateFilmRequest(string Title, string? Url);
///
/// Rating request.