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.