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>
This commit is contained in:
co-authored by
devitq
parent
fe4b40c3dd
commit
60756e376c
@@ -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 = '<h2 class="sectionTitle">MovieNight</h2><div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap;"></div>';
|
||||
section.innerHTML = `
|
||||
<div class="sectionTitleContainer" style="display:flex; align-items:center; justify-content:space-between;">
|
||||
<h2 class="sectionTitle">MovieNight</h2>
|
||||
<span class="movieNightSyncStatus" style="font-size:0.8em; opacity:0.7;"></span>
|
||||
</div>
|
||||
<div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap; margin-top:0.5em;"></div>
|
||||
`;
|
||||
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 = `
|
||||
<h2 style="margin-top:0; text-align:center;">Rate on MovieNight</h2>
|
||||
<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.5em; margin:1.5em 0;"></div>
|
||||
<button is="emby-button" class="emby-button button-flat btnCancel" style="width:100%; color: white;">Cancel</button>
|
||||
<h2 style="margin-top:0; text-align:center; font-weight:400;">${title}</h2>
|
||||
<div class="dialog-content" style="margin:1.5em 0;"></div>
|
||||
<div class="dialog-footer" style="display:flex; gap:1em;">
|
||||
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white;">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function showRatingDialog(itemId) {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Rate on MovieNight');
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
|
||||
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.6em;"></div>`;
|
||||
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 = `
|
||||
<div style="margin-bottom:1em;">
|
||||
<label style="display:block; margin-bottom:0.4em; font-size:0.9em; opacity:0.8;">Movie Title</label>
|
||||
<input type="text" class="emby-input txtTitle" style="width:100%; box-sizing:border-box;" placeholder="e.g. Inception">
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:0.4em; font-size:0.9em; opacity:0.8;">Stream URL (Optional)</label>
|
||||
<input type="text" class="emby-input txtUrl" style="width:100%; box-sizing:border-box;" placeholder="http://...">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const btnAdd = document.createElement('button');
|
||||
btnAdd.className = 'emby-button raised button-submit';
|
||||
btnAdd.style.flex = '2';
|
||||
btnAdd.innerHTML = '<span>Add Film</span>';
|
||||
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.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace Jellyfin.Plugin.MovieNight.Controllers;
|
||||
/// Admin endpoints for the MovieNight plugin.
|
||||
/// </summary>
|
||||
[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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ping endpoint for connectivity checks.
|
||||
/// </summary>
|
||||
[HttpGet("Ping")]
|
||||
[AllowAnonymous]
|
||||
public ActionResult Ping() => Ok("Pong");
|
||||
|
||||
/// <summary>
|
||||
/// Returns plugin status.
|
||||
/// </summary>
|
||||
@@ -50,6 +57,7 @@ public class MovieNightController : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Connection result.</returns>
|
||||
[HttpPost("TestConnection")]
|
||||
[Authorize(Policy = "RequiresAdmin")]
|
||||
public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -61,6 +69,7 @@ public class MovieNightController : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response.</returns>
|
||||
[HttpPost("Sync")]
|
||||
[Authorize(Policy = "RequiresAdmin")]
|
||||
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -73,6 +82,7 @@ public class MovieNightController : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response.</returns>
|
||||
[HttpGet("SyncState")]
|
||||
[Authorize(Policy = "RequiresAdmin")]
|
||||
public async Task<ActionResult<string>> 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.
|
||||
/// </summary>
|
||||
[HttpPost("Films")]
|
||||
[Authorize(Policy = "RequiresAdmin")]
|
||||
public async Task<ActionResult> 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
|
||||
/// <summary>
|
||||
/// Create film request.
|
||||
/// </summary>
|
||||
public sealed record CreateFilmRequest(string Title);
|
||||
public sealed record CreateFilmRequest(string Title, string? Url);
|
||||
|
||||
/// <summary>
|
||||
/// Rating request.
|
||||
|
||||
Reference in New Issue
Block a user