feat: Jellyfin Plugin UI Integration #51
@@ -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,17 +65,13 @@
|
|||||||
|
|
||||||
// 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();
|
}));
|
||||||
}));
|
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
|
||||||
}
|
e.preventDefault(); showAddMovieDialog();
|
||||||
if (!toolBar.querySelector('.btnMovieNightAddMovie')) {
|
}));
|
||||||
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
|
|
||||||
e.preventDefault(); showAddMovieDialog();
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Home Page - Prepend a MovieNight section
|
// 3. Home Page - Prepend a MovieNight section
|
||||||
@@ -123,7 +113,7 @@
|
|||||||
const overlay = document.createElement('div');
|
const overlay = document.createElement('div');
|
||||||
|
|
|||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user
itemIdis 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 usingToString("N")(no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URLidto the same canonical format before calling the API (and applyingencodeURIComponentwhen interpolating path segments).