From 0a90c3eadf9deaea8490f36cda497041467443cf Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 17:35:48 +0000 Subject: [PATCH 01/12] Refactor Jellyfin plugin: integrate UI components and implement backend contract. - Integrated "Recommend Film" button and Rating UI into Jellyfin web interface via MutationObserver. - Implemented full library sync (metadata + user state) in MovieNightSyncService. - Updated MovieNightBackendClient to support sync, recommendations, ratings, and viewed status. - Added proxy endpoints to MovieNightController for frontend-backend communication. - Refined sync logic to handle large libraries and ensured .NET 9.0 compatibility. Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../Configuration/config.js | 5 + .../Configuration/configPage.html | 6 ++ .../Configuration/ui.js | 96 +++++++++++++++++++ .../Controllers/MovieNightController.cs | 60 +++++++++++- .../Jellyfin.Plugin.MovieNight.csproj | 2 + .../Jellyfin.Plugin.MovieNight/Plugin.cs | 8 ++ .../PluginServiceRegistrator.cs | 1 + .../Services/MovieNightBackendClient.cs | 64 ++++++++++++- .../Services/MovieNightPeriodicSyncService.cs | 18 ++-- .../Services/MovieNightSyncService.cs | 95 ++++++++++++++++++ 10 files changed, 344 insertions(+), 11 deletions(-) create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js index c259fba..22a86cf 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -16,6 +16,11 @@ const movieNightConfigPage = { config.EnablePeriodicSync !== false; view.querySelector("#EnablePlaybackEvents").checked = config.EnablePlaybackEvents !== false; + + const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", { + name: "MovieNight.ui.js", + }); + view.querySelector("#UIScriptUrl").innerText = uiScriptUrl; }) .finally(() => { Dashboard.hideLoadingMsg(); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html index 675f7ea..d4194fe 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -53,6 +53,12 @@ Test connection + +
+

UI Integration

+

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(); + + foreach (var item in items) + { + if (item is not Movie movie) continue; + + var jellyfinItemId = movie.Id.ToString("N"); + + var itemData = new Dictionary + { + ["jellyfinItemId"] = jellyfinItemId, + ["title"] = movie.Name, + ["originalTitle"] = movie.OriginalTitle, + ["description"] = movie.Overview, + ["year"] = movie.ProductionYear, + ["duration"] = movie.RunTimeTicks, + ["genres"] = movie.Genres, + ["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb), + ["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb), + ["userStates"] = users.Select(u => { + var userData = _userDataManager.GetUserData(u, movie); + return new { + jellyfinUserId = u.Id.ToString("N"), + isViewed = userData?.Played ?? false, + playCount = userData?.PlayCount ?? 0, + lastPlayedAt = userData?.LastPlayedDate, + userRating = userData?.Rating + }; + }).ToList() + }; + + syncItems.Add(itemData); + } + + await _backendClient.SyncAsync(new { items = syncItems }, cancellationToken).ConfigureAwait(false); + _logger.LogInformation("MovieNight library sync completed"); + } +} -- 2.54.0 From 4c39020689db924fbcc0fa360d585f8db25ee991 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 18:22:43 +0000 Subject: [PATCH 02/12] fix(plugin): address PR review feedback for sync, auth, and UI hooks Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/fafc7e2d-7464-44d7-a4e1-42f364dd0e37 Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../Configuration/ui.js | 28 +++++++++-- .../Controllers/MovieNightController.cs | 50 +++++++++++++++++-- .../Services/MovieNightPeriodicSyncService.cs | 8 --- .../Services/MovieNightSyncService.cs | 34 +++++++++---- 4 files changed, 95 insertions(+), 25 deletions(-) diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js index 0bb7d35..2693524 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -46,13 +46,22 @@ function getItemIdFromUrl() { const params = new URLSearchParams(window.location.search); - return params.get('id'); + return normalizeJellyfinId(params.get('id')); + } + + function normalizeJellyfinId(value) { + if (!value) { + return null; + } + + const normalized = value.replace(/-/g, '').toLowerCase(); + return /^[0-9a-f]{32}$/.test(normalized) ? normalized : null; } async function showRecommendation() { const userId = ApiClient.getCurrentUserId(); try { - const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`)); + const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${encodeURIComponent(userId)}/Recommendations`)); const recommendations = typeof response === 'string' ? JSON.parse(response) : response; if (recommendations && recommendations.length > 0) { @@ -77,7 +86,7 @@ try { await ApiClient.ajax({ type: 'POST', - url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`), + url: ApiClient.getUrl(`MovieNight/Users/${encodeURIComponent(userId)}/Ratings/Films/${encodeURIComponent(itemId)}`), data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }), contentType: 'application/json' }); @@ -88,7 +97,18 @@ } } - const observer = new MutationObserver(injectUI); + let pendingInjection = false; + const observer = new MutationObserver(() => { + if (pendingInjection) { + return; + } + + pendingInjection = true; + requestAnimationFrame(() => { + pendingInjection = false; + injectUI(); + }); + }); observer.observe(document.body, { childList: true, subtree: true }); // Initial call diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs index 49c8fbe..542ddb9 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -1,4 +1,5 @@ using System; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.MovieNight.Services; @@ -88,7 +89,12 @@ public class MovieNightController : ControllerBase [FromQuery] int limit = 10, CancellationToken cancellationToken = default) { - return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); + if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult)) + { + return errorResult!; + } + + return await _backendClient.GetRecommendationsAsync(authenticatedUserId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); } /// @@ -101,7 +107,12 @@ public class MovieNightController : ControllerBase [FromBody] RatingRequest request, CancellationToken cancellationToken) { - await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false); + if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult)) + { + return errorResult!; + } + + await _backendClient.PostRatingAsync(authenticatedUserId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false); return Ok(); } @@ -115,9 +126,42 @@ public class MovieNightController : ControllerBase [FromBody] ViewedRequest request, CancellationToken cancellationToken) { - await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); + if (!TryValidateCurrentUserId(userId, out var authenticatedUserId, out var errorResult)) + { + return errorResult!; + } + + await _backendClient.MarkViewedAsync(authenticatedUserId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); return Ok(); } + + private bool TryValidateCurrentUserId(string routeUserId, out string authenticatedUserId, out ActionResult? errorResult) + { + authenticatedUserId = NormalizeId( + HttpContext.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? + HttpContext.User.FindFirstValue("JellyfinUserId") ?? + HttpContext.User.FindFirstValue("UserId")); + if (string.IsNullOrWhiteSpace(authenticatedUserId)) + { + errorResult = Unauthorized(); + return false; + } + + var normalizedRouteUserId = NormalizeId(routeUserId); + if (!string.Equals(authenticatedUserId, normalizedRouteUserId, StringComparison.OrdinalIgnoreCase)) + { + errorResult = Forbid(); + return false; + } + + errorResult = null; + return true; + } + + private static string NormalizeId(string? value) + { + return Guid.TryParse(value, out var parsedGuid) ? parsedGuid.ToString("N") : string.Empty; + } } /// diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs index bd72c5e..e193b02 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs @@ -1,14 +1,6 @@ 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; diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs index f0b31f1..39d6ca9 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -59,9 +59,32 @@ public class MovieNightSyncService foreach (var item in items) { + cancellationToken.ThrowIfCancellationRequested(); + if (item is not Movie movie) continue; var jellyfinItemId = movie.Id.ToString("N"); + var userStates = new List(); + + foreach (var user in users) + { + cancellationToken.ThrowIfCancellationRequested(); + + var userData = _userDataManager.GetUserData(user, movie); + if (userData is null || (!userData.Played && userData.PlayCount == 0 && userData.LastPlayedDate is null && userData.Rating is null)) + { + continue; + } + + userStates.Add(new + { + jellyfinUserId = user.Id.ToString("N"), + isViewed = userData.Played, + playCount = userData.PlayCount, + lastPlayedAt = userData.LastPlayedDate, + userRating = userData.Rating + }); + } var itemData = new Dictionary { @@ -74,16 +97,7 @@ public class MovieNightSyncService ["genres"] = movie.Genres, ["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb), ["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb), - ["userStates"] = users.Select(u => { - var userData = _userDataManager.GetUserData(u, movie); - return new { - jellyfinUserId = u.Id.ToString("N"), - isViewed = userData?.Played ?? false, - playCount = userData?.PlayCount ?? 0, - lastPlayedAt = userData?.LastPlayedDate, - userRating = userData?.Rating - }; - }).ToList() + ["userStates"] = userStates }; syncItems.Add(itemData); -- 2.54.0 From 07c9995c3cfda2a7c2d5d04b14fc04f4492f18ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 20 May 2026 18:53:19 +0000 Subject: [PATCH 03/12] fix(jellyfin): check cancellation before library query Agent-Logs-Url: https://github.com/devitq/movienight-backend/sessions/7063bb08-680e-403b-b42d-355fd2ad03aa Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../Services/MovieNightSyncService.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs index 39d6ca9..b1f39c5 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -48,6 +48,8 @@ public class MovieNightSyncService { _logger.LogInformation("Starting MovieNight library sync"); + cancellationToken.ThrowIfCancellationRequested(); + var items = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie }, -- 2.54.0 From 841ce8534f3095c34c550956200b021b2855b409 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Wed, 20 May 2026 19:54:11 +0000 Subject: [PATCH 04/12] Refactor Jellyfin plugin: UI integration, backend contract, and STRM creation. - Integrated "Recommend Film", "Add Movie (STRM)", and Rating UI into Jellyfin web interface. - Implemented full library sync (metadata with poster URLs, user states) with library filtering support. - Added support for creating films via .strm file generation. - Updated MovieNightBackendClient to support sync, recommendations, ratings, and viewed status. - Added proxy endpoints to MovieNightController for frontend-backend communication. - Ensured compatibility with Jellyfin 10.11.x and .NET 9.0. Co-authored-by: devitq <118541411+devitq@users.noreply.github.com> --- .../Configuration/PluginConfiguration.cs | 5 + .../Configuration/config.js | 3 + .../Configuration/configPage.html | 6 + .../Configuration/ui.js | 103 +++++++++++------- .../Controllers/MovieNightController.cs | 75 ++++++------- .../Services/MovieNightPeriodicSyncService.cs | 8 ++ .../Services/MovieNightSyncService.cs | 48 ++++---- 7 files changed, 141 insertions(+), 107 deletions(-) diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs index 5e6ef14..2e97960 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs @@ -42,4 +42,9 @@ public class PluginConfiguration : BasePluginConfiguration /// Gets or sets enabled Jellyfin library ids. Empty means all libraries. /// public List EnabledLibraryIds { get; set; } = new(); + + /// + /// Gets or sets the path where .strm files will be created. + /// + public string StrmOutputPath { get; set; } = string.Empty; } diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js index 22a86cf..7e38881 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -11,6 +11,8 @@ const movieNightConfigPage = { view.querySelector("#ApiToken").value = config.ApiToken || ""; view.querySelector("#SyncIntervalMinutes").value = config.SyncIntervalMinutes || 30; + view.querySelector("#StrmOutputPath").value = + config.StrmOutputPath || ""; view.querySelector("#Enabled").checked = config.Enabled || false; view.querySelector("#EnablePeriodicSync").checked = config.EnablePeriodicSync !== false; @@ -39,6 +41,7 @@ const movieNightConfigPage = { form.querySelector("#SyncIntervalMinutes").value || "30", 10, ); + config.StrmOutputPath = form.querySelector("#StrmOutputPath").value; config.Enabled = form.querySelector("#Enabled").checked; config.EnablePeriodicSync = form.querySelector("#EnablePeriodicSync").checked; diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html index d4194fe..adb20db 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -27,6 +27,12 @@ +
+ + +
Directory where .strm files will be created for new films.
+
+