To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin Custom JavaScript setting (Dashboard > General):
+
+
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
new file mode 100644
index 0000000..0bb7d35
--- /dev/null
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
@@ -0,0 +1,96 @@
+(function () {
+ const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
+
+ function injectUI() {
+ // 1. Inject "Recommend me a film" button in Library views
+ const headerButtons = document.querySelector('.headerViewButtons');
+ if (headerButtons && !document.querySelector('.btnMovieNightRecommend')) {
+ const btn = document.createElement('button');
+ btn.className = 'emby-button raised btnMovieNightRecommend';
+ btn.innerHTML = 'Recommend Film';
+ btn.style.marginLeft = '1em';
+ btn.onclick = showRecommendation;
+ headerButtons.appendChild(btn);
+ }
+
+ // 2. Inject Rating UI in Item Details
+ const detailButtons = document.querySelector('.itemDetailButtons');
+ if (detailButtons && !document.querySelector('.movieNightRatingContainer')) {
+ const itemId = getItemIdFromUrl();
+ if (itemId) {
+ const container = document.createElement('div');
+ container.className = 'movieNightRatingContainer';
+ container.style.display = 'inline-flex';
+ container.style.alignItems = 'center';
+ container.style.marginLeft = '1em';
+
+ const label = document.createElement('span');
+ label.innerText = 'MovieNight: ';
+ container.appendChild(label);
+
+ const select = document.createElement('select');
+ select.className = 'emby-select';
+ for (let i = 0; i <= 10; i++) {
+ const opt = document.createElement('option');
+ opt.value = i;
+ opt.innerText = i === 0 ? 'Rate...' : i;
+ select.appendChild(opt);
+ }
+ select.onchange = (e) => submitRating(itemId, e.target.value);
+ container.appendChild(select);
+
+ detailButtons.appendChild(container);
+ }
+ }
+ }
+
+ function getItemIdFromUrl() {
+ const params = new URLSearchParams(window.location.search);
+ return params.get('id');
+ }
+
+ async function showRecommendation() {
+ const userId = ApiClient.getCurrentUserId();
+ try {
+ const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
+ const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
+
+ if (recommendations && recommendations.length > 0) {
+ const rec = recommendations[0];
+ const film = rec.film || rec;
+ Dashboard.alert({
+ title: 'MovieNight Recommendation',
+ text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}`
+ });
+ } else {
+ Dashboard.alert('No recommendations found at the moment.');
+ }
+ } catch (err) {
+ console.error('Failed to get recommendations', err);
+ Dashboard.alert('Failed to get recommendations from MovieNight.');
+ }
+ }
+
+ async function submitRating(itemId, score) {
+ if (score === "0") return;
+ const userId = ApiClient.getCurrentUserId();
+ try {
+ await ApiClient.ajax({
+ type: 'POST',
+ url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
+ data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
+ contentType: 'application/json'
+ });
+ Dashboard.alert('Rating submitted!');
+ } catch (err) {
+ console.error('Failed to submit rating', err);
+ Dashboard.alert('Failed to submit rating to MovieNight.');
+ }
+ }
+
+ const observer = new MutationObserver(injectUI);
+ observer.observe(document.body, { childList: true, subtree: true });
+
+ // Initial call
+ injectUI();
+})();
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
index bc0ede8..49c8fbe 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
@@ -16,14 +16,15 @@ namespace Jellyfin.Plugin.MovieNight.Controllers;
public class MovieNightController : ControllerBase
{
private readonly MovieNightBackendClient _backendClient;
+ private readonly MovieNightSyncService _syncService;
///
/// Initializes a new instance of the class.
///
- /// Backend client.
- public MovieNightController(MovieNightBackendClient backendClient)
+ public MovieNightController(MovieNightBackendClient backendClient, MovieNightSyncService syncService)
{
_backendClient = backendClient;
+ _syncService = syncService;
}
///
@@ -61,7 +62,8 @@ public class MovieNightController : ControllerBase
[HttpPost("Sync")]
public async Task> Sync(CancellationToken cancellationToken)
{
- return await _backendClient.TriggerSyncAsync(cancellationToken).ConfigureAwait(false);
+ await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
+ return Ok("Sync triggered");
}
///
@@ -74,8 +76,60 @@ public class MovieNightController : ControllerBase
{
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
}
+
+ ///
+ /// Gets recommendations for the current user.
+ ///
+ [HttpGet("Users/{userId}/Recommendations")]
+ public async Task> GetRecommendations(
+ [FromRoute] string userId,
+ [FromQuery] string? contentType,
+ [FromQuery] string? mood,
+ [FromQuery] int limit = 10,
+ CancellationToken cancellationToken = default)
+ {
+ return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Posts a rating for a film.
+ ///
+ [HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
+ public async Task PostRating(
+ [FromRoute] string userId,
+ [FromRoute] string filmId,
+ [FromBody] RatingRequest request,
+ CancellationToken cancellationToken)
+ {
+ await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
+ return Ok();
+ }
+
+ ///
+ /// Marks a film as viewed.
+ ///
+ [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
+ public async Task MarkViewed(
+ [FromRoute] string userId,
+ [FromRoute] string filmId,
+ [FromBody] ViewedRequest request,
+ CancellationToken cancellationToken)
+ {
+ await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
+ return Ok();
+ }
}
+///
+/// Rating request.
+///
+public sealed record RatingRequest(int Score, string? Note);
+
+///
+/// Viewed request.
+///
+public sealed record ViewedRequest(DateTimeOffset? WatchedAt);
+
///
/// MovieNight plugin status response.
///
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj
index 5952317..a8e6ec0 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj
@@ -27,8 +27,10 @@
+
+
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs
index 40c8dd9..73a5718 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs
@@ -59,6 +59,14 @@ public class Plugin : BasePlugin, IHasWebPages
CultureInfo.InvariantCulture,
"{0}.Configuration.config.js",
GetType().Namespace)
+ },
+ new PluginPageInfo
+ {
+ Name = Name + ".ui.js",
+ EmbeddedResourcePath = string.Format(
+ CultureInfo.InvariantCulture,
+ "{0}.Configuration.ui.js",
+ GetType().Namespace)
}
];
}
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs
index 50e96f2..b4fc366 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs
@@ -14,6 +14,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
serviceCollection.AddHostedService();
serviceCollection.AddHostedService();
}
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs
index f6bb51f..58b8e62 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs
@@ -70,11 +70,12 @@ public class MovieNightBackendClient
}
///
- /// Triggers the current backend Jellyfin sync endpoint.
+ /// Pushes library sync data to the backend.
///
+ /// Sync payload.
/// Cancellation token.
/// Backend response body.
- public async Task TriggerSyncAsync(CancellationToken cancellationToken)
+ public async Task SyncAsync(object payload, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync");
if (request is null)
@@ -82,12 +83,71 @@ public class MovieNightBackendClient
return "Plugin is not configured.";
}
+ request.Content = JsonContent.Create(payload, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
+ ///
+ /// Gets recommendations for a user.
+ ///
+ public async Task GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken)
+ {
+ var query = $"?limit={limit}";
+ if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}";
+ if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}";
+
+ var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/recommendations{query}");
+ if (request is null) return "Plugin is not configured.";
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ return body;
+ }
+
+ ///
+ /// Posts a rating for a film.
+ ///
+ public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken)
+ {
+ var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/ratings/films/{filmId}");
+ if (request is null) return;
+
+ request.Content = JsonContent.Create(new { score, note }, options: JsonOptions);
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ }
+
+ ///
+ /// Gets ratings for a user.
+ ///
+ public async Task GetRatingsAsync(string userId, CancellationToken cancellationToken)
+ {
+ var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/ratings");
+ if (request is null) return "[]";
+
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ return body;
+ }
+
+ ///
+ /// Marks a film as viewed.
+ ///
+ public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken)
+ {
+ var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed");
+ if (request is null) return;
+
+ request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions);
+ using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+ }
+
///
/// Reads backend sync state.
///
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs
index 92c9861..bd72c5e 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs
@@ -1,6 +1,14 @@
using System;
+using System.Collections.Generic;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Jellyfin.Data.Enums;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -11,19 +19,17 @@ namespace Jellyfin.Plugin.MovieNight.Services;
///
public sealed class MovieNightPeriodicSyncService : BackgroundService
{
- private readonly MovieNightBackendClient _backendClient;
+ private readonly MovieNightSyncService _syncService;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
- /// Backend client.
- /// Logger.
public MovieNightPeriodicSyncService(
- MovieNightBackendClient backendClient,
+ MovieNightSyncService syncService,
ILogger logger)
{
- _backendClient = backendClient;
+ _syncService = syncService;
_logger = logger;
}
@@ -41,7 +47,7 @@ public sealed class MovieNightPeriodicSyncService : BackgroundService
continue;
}
- await _backendClient.TriggerSyncAsync(stoppingToken).ConfigureAwait(false);
+ await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs
new file mode 100644
index 0000000..f0b31f1
--- /dev/null
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs
@@ -0,0 +1,95 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.Movies;
+using MediaBrowser.Model.Entities;
+using MediaBrowser.Model.Querying;
+using Jellyfin.Data.Enums;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.MovieNight.Services;
+
+///
+/// Service for synchronizing the Jellyfin library with MovieNight.
+///
+public class MovieNightSyncService
+{
+ private readonly MovieNightBackendClient _backendClient;
+ private readonly ILibraryManager _libraryManager;
+ private readonly IUserManager _userManager;
+ private readonly IUserDataManager _userDataManager;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public MovieNightSyncService(
+ MovieNightBackendClient backendClient,
+ ILibraryManager libraryManager,
+ IUserManager userManager,
+ IUserDataManager userDataManager,
+ ILogger logger)
+ {
+ _backendClient = backendClient;
+ _libraryManager = libraryManager;
+ _userManager = userManager;
+ _userDataManager = userDataManager;
+ _logger = logger;
+ }
+
+ ///
+ /// Performs a full library sync.
+ ///
+ public async Task PerformSyncAsync(CancellationToken cancellationToken)
+ {
+ _logger.LogInformation("Starting MovieNight library sync");
+
+ var items = _libraryManager.GetItemList(new InternalItemsQuery
+ {
+ IncludeItemTypes = new[] { BaseItemKind.Movie },
+ Recursive = true
+ });
+
+ var users = _userManager.Users;
+ var syncItems = new List