diff --git a/plugins/jellyfin/.gitignore b/plugins/jellyfin/.gitignore deleted file mode 100644 index 5967294..0000000 --- a/plugins/jellyfin/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -**/bin/ -**/obj/ diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs deleted file mode 100644 index 2e97960..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs +++ /dev/null @@ -1,50 +0,0 @@ -using System.Collections.Generic; -using MediaBrowser.Model.Plugins; - -namespace Jellyfin.Plugin.MovieNight.Configuration; - -/// -/// MovieNight plugin settings persisted by Jellyfin. -/// -public class PluginConfiguration : BasePluginConfiguration -{ - /// - /// Gets or sets a value indicating whether integration calls are enabled. - /// - public bool Enabled { get; set; } - - /// - /// Gets or sets the MovieNight backend base URL. - /// - public string BackendBaseUrl { get; set; } = string.Empty; - - /// - /// Gets or sets the backend plugin token. - /// - public string ApiToken { get; set; } = string.Empty; - - /// - /// Gets or sets the periodic sync interval in minutes. - /// - public int SyncIntervalMinutes { get; set; } = 30; - - /// - /// Gets or sets a value indicating whether playback stop events are pushed to MovieNight. - /// - public bool EnablePlaybackEvents { get; set; } = true; - - /// - /// Gets or sets a value indicating whether periodic backend sync is enabled. - /// - public bool EnablePeriodicSync { get; set; } = true; - - /// - /// 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 deleted file mode 100644 index 7e38881..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js +++ /dev/null @@ -1,94 +0,0 @@ -const movieNightConfigPage = { - pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb", - - loadConfiguration(view) { - Dashboard.showLoadingMsg(); - - return ApiClient.getPluginConfiguration(this.pluginId) - .then((config) => { - view.querySelector("#BackendBaseUrl").value = - config.BackendBaseUrl || ""; - 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; - 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(); - }); - }, - - saveConfiguration(view) { - const form = view.querySelector("#MovieNightConfigForm"); - Dashboard.showLoadingMsg(); - - return ApiClient.getPluginConfiguration(this.pluginId) - .then((config) => { - config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value; - config.ApiToken = form.querySelector("#ApiToken").value; - config.SyncIntervalMinutes = parseInt( - form.querySelector("#SyncIntervalMinutes").value || "30", - 10, - ); - config.StrmOutputPath = form.querySelector("#StrmOutputPath").value; - config.Enabled = form.querySelector("#Enabled").checked; - config.EnablePeriodicSync = - form.querySelector("#EnablePeriodicSync").checked; - config.EnablePlaybackEvents = - form.querySelector("#EnablePlaybackEvents").checked; - - return ApiClient.updatePluginConfiguration(this.pluginId, config); - }) - .then((result) => { - Dashboard.processPluginConfigurationUpdateResult(result); - }) - .finally(() => { - Dashboard.hideLoadingMsg(); - }); - }, - - testConnection() { - Dashboard.showLoadingMsg(); - - return ApiClient.ajax({ - type: "POST", - url: ApiClient.getUrl("MovieNight/TestConnection"), - }) - .then((result) => { - Dashboard.alert((result && result.message) || "OK"); - }) - .catch(() => { - Dashboard.alert("MovieNight connection test failed"); - }) - .finally(() => { - Dashboard.hideLoadingMsg(); - }); - }, -}; - -export default function (view) { - movieNightConfigPage.loadConfiguration(view); - - view - .querySelector("#MovieNightConfigForm") - .addEventListener("submit", (event) => { - event.preventDefault(); - movieNightConfigPage.saveConfiguration(view); - }); - - view.querySelector("#TestConnection").addEventListener("click", (event) => { - event.preventDefault(); - movieNightConfigPage.testConnection(); - }); -} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html deleted file mode 100644 index adb20db..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html +++ /dev/null @@ -1,74 +0,0 @@ - - - - MovieNight - - -
-
-
-
-
- - -
- -
- - -
- -
- - -
- -
- - -
Directory where .strm files will be created for new films.
-
- - - - - - - -
- -
- -
- -
- -
-

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 deleted file mode 100644 index 4786f13..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js +++ /dev/null @@ -1,339 +0,0 @@ -(function () { - const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb"; - - function getAlert() { - if (typeof Dashboard !== 'undefined' && Dashboard.alert) { - return (options) => Dashboard.alert(options); - } - return (options) => { - const msg = typeof options === 'string' ? options : (options.text || options.title); - alert(msg); - }; - } - - const showMsg = getAlert(); - - function createTextButton(text, className, onClick) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.is = 'emby-button'; - btn.className = `emby-button raised ${className}`; - btn.style.margin = '0.5em'; - btn.style.padding = '0.4em 1em'; - btn.innerHTML = `${text}`; - btn.onclick = onClick; - return btn; - } - - function createIconButton(icon, title, className, onClick) { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.is = 'emby-button'; - btn.className = `button-flat detailButton emby-button ${className}`; - btn.title = title; - btn.innerHTML = ` -
- -
- `; - btn.onclick = onClick; - return btn; - } - - function injectUI() { - // 1. Item Detail Page - const detailButtons = document.querySelector('.mainDetailButtons'); - if (detailButtons) { - const itemId = getItemIdFromUrl(); - if (itemId) { - // 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); - } - } - } - - // 2. Library Pages - Add text buttons to toolbar - 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(); - })); - toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => { - e.preventDefault(); showAddMovieDialog(); - })); - } - - // 3. Home Page - Prepend a MovieNight section - const homeSections = document.querySelector('.sections.homeSectionsContainer'); - if (homeSections && !document.querySelector('.movieNightHomeButtons')) { - const section = document.createElement('div'); - section.className = 'verticalSection movieNightHomeButtons'; - section.style.padding = '0 var(--sidePadding)'; - section.innerHTML = ` -
-

MovieNight

- -
-
- `; - const btnContainer = section.querySelector('.movieNightBtnContainer'); - btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation)); - 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'); - } - - function createOverlay() { - const overlay = document.createElement('div'); - overlay.className = 'dialogBackdrop dialogBackdropOpened'; - overlay.style.zIndex = '99998'; - overlay.style.backgroundColor = 'rgba(0,0,0,0.7)'; - overlay.style.position = 'fixed'; - overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0'; - overlay.style.backdropFilter = 'blur(8px)'; - overlay.style.opacity = '1'; - 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.transform = 'translate(-50%, -50%)'; - dialog.style.zIndex = '99999'; - dialog.style.padding = '2.5em'; - dialog.style.minWidth = '350px'; - dialog.style.backgroundColor = '#1a1a1a'; - dialog.style.borderRadius = '1.5em'; - dialog.style.color = 'white'; - dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)'; - dialog.style.border = '1px solid #444'; - dialog.style.opacity = '1'; - - dialog.innerHTML = ` -

${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); }; - - for (let i = 1; i <= 10; i++) { - const btn = document.createElement('button'); - btn.type = 'button'; btn.is = 'emby-button'; - btn.className = 'emby-button raised'; - btn.innerText = i; - btn.style.padding = '0.8em 0'; - btn.style.textAlign = 'center'; - btn.style.display = 'flex'; - btn.style.alignItems = 'center'; - btn.style.justifyContent = 'center'; - btn.style.fontSize = '1.2em'; - btn.onclick = async () => { cleanup(); await submitRating(itemId, i); }; - grid.appendChild(btn); - } - - 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.style.backgroundColor = '#0064d2'; - 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 year = dialog.querySelector('.txtYear').value; - const imdbId = dialog.querySelector('.txtImdb').value; - const url = dialog.querySelector('.txtUrl').value; - if (!title) return; - cleanup(); - await addMovie(title, url, year, imdbId); - }; - - 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() { - 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; - showMsg({ - title: 'MovieNight Recommendation', - text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}` - }); - } else { - showMsg('No recommendations found at the moment.'); - } - } catch (err) { - console.error('Failed to get recommendations', err); - showMsg('Failed to get recommendations. Check your API token and MovieNight status.'); - } - } - - async function addMovie(title, url, year, imdbId) { - try { - const data = { title, url }; - if (year) data.year = parseInt(year); - if (imdbId) data.imdbId = imdbId; - - await ApiClient.ajax({ - type: 'POST', - url: ApiClient.getUrl(`MovieNight/Films`), - data: JSON.stringify(data), - 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. 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 { - 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' - }); - showMsg('Rating submitted to MovieNight!'); - } catch (err) { - 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.'); - } - } - - let timeout; - const throttledInject = () => { - if (timeout) return; - timeout = setTimeout(() => { - injectUI(); - timeout = null; - }, 100); - }; - - const observer = new MutationObserver(throttledInject); - observer.observe(document.body, { childList: true, subtree: true }); - - injectUI(); -})(); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs deleted file mode 100644 index 58b51ac..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs +++ /dev/null @@ -1,229 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Jellyfin.Plugin.MovieNight.Services; -using MediaBrowser.Controller.Library; -using Microsoft.AspNetCore.Authorization; -using Microsoft.AspNetCore.Mvc; - -namespace Jellyfin.Plugin.MovieNight.Controllers; - -/// -/// Admin endpoints for the MovieNight plugin. -/// -[ApiController] -[Route("MovieNight")] -public class MovieNightController : ControllerBase -{ - private readonly MovieNightBackendClient _backendClient; - private readonly MovieNightSyncService _syncService; - - /// - /// Initializes a new instance of the class. - /// - public MovieNightController( - MovieNightBackendClient backendClient, - MovieNightSyncService syncService) - { - _backendClient = backendClient; - _syncService = syncService; - } - - /// - /// Ping endpoint for connectivity checks. - /// - [HttpGet("Ping")] - public ActionResult Ping() => Ok("Pong"); - - /// - /// Returns plugin status. - /// - /// Status response. - [HttpGet("Status")] - [Authorize] - public ActionResult GetStatus() - { - var configuration = Plugin.Instance?.Configuration; - return new MovieNightPluginStatus( - Enabled: configuration?.Enabled ?? false, - BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty, - PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false, - PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false, - SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30); - } - - /// - /// Tests backend connectivity. - /// - /// Cancellation token. - /// Connection result. - [HttpPost("TestConnection")] - [Authorize] - public async Task> TestConnection(CancellationToken cancellationToken) - { - return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); - } - - /// - /// Triggers backend sync. - /// - /// Cancellation token. - /// Backend response. - [HttpPost("Sync")] - [Authorize] - public async Task> Sync(CancellationToken cancellationToken) - { - await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); - return Ok("Sync triggered"); - } - - /// - /// Gets backend sync state. - /// - /// Cancellation token. - /// Backend response. - [HttpGet("SyncState")] - [Authorize] - public async Task> SyncState(CancellationToken cancellationToken) - { - return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); - } - - /// - /// Gets recommendations for the current user. - /// - [HttpGet("Users/{userId}/Recommendations")] - [Authorize] - 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}")] - [Authorize] - 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")] - [Authorize] - 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(); - } - - /// - /// Creates a new film by generating a .strm file in a folder-per-movie structure. - /// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm - /// - [HttpPost("Films")] - [Authorize] - public async Task CreateFilm([FromBody] CreateFilmRequest request) - { - var config = Plugin.Instance?.Configuration; - if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath)) - { - return BadRequest("STRM output path is not configured."); - } - - if (string.IsNullOrWhiteSpace(request.Title)) - { - return BadRequest("Movie title is required."); - } - - try - { - // Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]" - var folderName = request.Title.Trim(); - if (request.Year.HasValue) - { - folderName += $" ({request.Year})"; - } - if (!string.IsNullOrWhiteSpace(request.ImdbId)) - { - var ttId = request.ImdbId.Trim().ToLowerInvariant(); - if (!ttId.StartsWith("tt")) ttId = "tt" + ttId; - folderName += $" [imdbid-{ttId}]"; - } - - // Sanitize for file system - var invalidChars = Path.GetInvalidFileNameChars(); - var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); - - var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName); - if (!Directory.Exists(movieDirectory)) - { - Directory.CreateDirectory(movieDirectory); - } - - var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm"); - - var strmContent = string.IsNullOrWhiteSpace(request.Url) - ? "http://placeholder.url/upload_me_later" - : request.Url.Trim(); - - await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false); - - return Ok(new { FilePath = filePath, FolderName = safeFolderName }); - } - catch (Exception ex) - { - return StatusCode(500, $"Failed to create film: {ex.Message}"); - } - } -} - -/// -/// Create film request. -/// -public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId); - -/// -/// Rating request. -/// -public sealed record RatingRequest(int Score, string? Note); - -/// -/// Viewed request. -/// -public sealed record ViewedRequest(DateTimeOffset? WatchedAt); - -/// -/// MovieNight plugin status response. -/// -/// Whether integration is enabled. -/// Backend base URL. -/// Whether periodic sync is enabled. -/// Whether playback events are enabled. -/// Sync interval in minutes. -public sealed record MovieNightPluginStatus( - bool Enabled, - string BackendBaseUrl, - bool PeriodicSyncEnabled, - bool PlaybackEventsEnabled, - int SyncIntervalMinutes); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj deleted file mode 100644 index a8e6ec0..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj +++ /dev/null @@ -1,36 +0,0 @@ - - - - net9.0 - Jellyfin.Plugin.MovieNight - Jellyfin.Plugin.MovieNight - 1.0.0.1 - GPL-3.0-or-later - enable - true - false - - - - - - runtime - - - runtime - - - runtime - - - - - - - - - - - - - diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs deleted file mode 100644 index 73a5718..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs +++ /dev/null @@ -1,73 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Globalization; -using Jellyfin.Plugin.MovieNight.Configuration; -using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Plugins; -using MediaBrowser.Model.Plugins; -using MediaBrowser.Model.Serialization; - -namespace Jellyfin.Plugin.MovieNight; - -/// -/// MovieNight Jellyfin plugin. -/// -public class Plugin : BasePlugin, IHasWebPages -{ - /// - /// Initializes a new instance of the class. - /// - /// Application paths. - /// XML serializer. - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } - - /// - public override string Name => "MovieNight"; - - /// - public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb"); - - /// - public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend."; - - /// - /// Gets the current plugin instance. - /// - public static Plugin? Instance { get; private set; } - - /// - public IEnumerable GetPages() - { - return - [ - new PluginPageInfo - { - Name = Name, - EmbeddedResourcePath = string.Format( - CultureInfo.InvariantCulture, - "{0}.Configuration.configPage.html", - GetType().Namespace) - }, - new PluginPageInfo - { - Name = Name + ".js", - EmbeddedResourcePath = string.Format( - 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 deleted file mode 100644 index b4fc366..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs +++ /dev/null @@ -1,21 +0,0 @@ -using Jellyfin.Plugin.MovieNight.Services; -using MediaBrowser.Controller; -using MediaBrowser.Controller.Plugins; -using Microsoft.Extensions.DependencyInjection; - -namespace Jellyfin.Plugin.MovieNight; - -/// -/// Registers MovieNight services with Jellyfin. -/// -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 deleted file mode 100644 index 58b8e62..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs +++ /dev/null @@ -1,281 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Net.Http; -using System.Net.Http.Json; -using System.Text.Json; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Plugin.MovieNight.Services; - -/// -/// Thin HTTP client for the MovieNight backend. -/// -public class MovieNightBackendClient -{ - private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); - private readonly ILogger _logger; - private readonly HttpClient _httpClient; - - /// - /// Initializes a new instance of the class. - /// - /// Logger. - public MovieNightBackendClient(ILogger logger) - { - _logger = logger; - _httpClient = new HttpClient - { - Timeout = TimeSpan.FromSeconds(20) - }; - } - - /// - /// Calls backend health. - /// - /// Cancellation token. - /// Connection result. - public async Task TestConnectionAsync(CancellationToken cancellationToken) - { - var payload = new MovieNightEventPayload( - EventId: $"plugin-test:{Guid.NewGuid():N}", - EventType: "playback.stopped", - OccurredAt: DateTimeOffset.UtcNow, - JellyfinUserId: "movienight-plugin-test-user", - ItemId: "movienight-plugin-test-item", - PayloadVersion: 1, - Payload: new Dictionary - { - ["source"] = "config-test" - }); - var request = CreateEventRequest(payload); - if (request is null) - { - return MovieNightConnectionResult.Failed("Plugin is not configured."); - } - - try - { - using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - return response.IsSuccessStatusCode - ? MovieNightConnectionResult.Ok() - : MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}."); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) - { - _logger.LogWarning(ex, "MovieNight connection test failed"); - return MovieNightConnectionResult.Failed(ex.Message); - } - } - - /// - /// Pushes library sync data to the backend. - /// - /// Sync payload. - /// Cancellation token. - /// Backend response body. - public async Task SyncAsync(object payload, CancellationToken cancellationToken) - { - var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync"); - if (request is null) - { - 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. - /// - /// Cancellation token. - /// Backend response body. - public async Task GetSyncStateAsync(CancellationToken cancellationToken) - { - var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state"); - 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; - } - - /// - /// Pushes an event payload to the backend event endpoint. - /// - /// Event payload. - /// Cancellation token. - /// A task. - public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken) - { - for (var attempt = 1; attempt <= 3; attempt++) - { - var request = CreateEventRequest(payload); - if (request is null) - { - return; - } - - try - { - using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - if (response.IsSuccessStatusCode) - { - return; - } - - if ((int)response.StatusCode == 401) - { - _logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized"); - return; - } - - _logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode); - } - catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) - { - _logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt); - } - - if (attempt < 3) - { - await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false); - } - } - } - - private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload) - { - var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events"); - if (request is null) - { - return null; - } - - request.Content = JsonContent.Create(payload, options: JsonOptions); - return request; - } - - private static string? GetBaseUrl() - { - var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim(); - return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); - } - - private static bool IsEnabled() - { - var configuration = Plugin.Instance?.Configuration; - return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl); - } - - private static HttpRequestMessage? CreateRequest(HttpMethod method, string path) - { - if (!IsEnabled()) - { - return null; - } - - var baseUrl = GetBaseUrl(); - if (baseUrl is null) - { - return null; - } - - var request = new HttpRequestMessage(method, new Uri(baseUrl + path)); - var token = Plugin.Instance?.Configuration.ApiToken; - if (!string.IsNullOrWhiteSpace(token)) - { - request.Headers.Add("X-MovieNight-Plugin-Token", token); - } - - return request; - } -} - -/// -/// Backend connection result. -/// -/// Whether the call succeeded. -/// Result message. -public sealed record MovieNightConnectionResult(bool Success, string Message) -{ - /// - /// Creates a successful result. - /// - /// Connection result. - public static MovieNightConnectionResult Ok() => new(true, "OK"); - - /// - /// Creates a failed result. - /// - /// Failure message. - /// Connection result. - public static MovieNightConnectionResult Failed(string message) => new(false, message); -} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs deleted file mode 100644 index 926199b..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs +++ /dev/null @@ -1,31 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Text.Json.Serialization; - -namespace Jellyfin.Plugin.MovieNight.Services; - -/// -/// Event payload sent to MovieNight. -/// -/// Idempotency key. -/// Event type. -/// Event timestamp. -/// Jellyfin user id. -/// Jellyfin item id. -/// Payload version. -/// Extra event data. -public sealed record MovieNightEventPayload( - [property: JsonPropertyName("event_id")] - string EventId, - [property: JsonPropertyName("event_type")] - string EventType, - [property: JsonPropertyName("occurred_at")] - DateTimeOffset OccurredAt, - [property: JsonPropertyName("jellyfin_user_id")] - string JellyfinUserId, - [property: JsonPropertyName("item_id")] - string ItemId, - [property: JsonPropertyName("payload_version")] - int PayloadVersion, - [property: JsonPropertyName("payload")] - IReadOnlyDictionary Payload); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs deleted file mode 100644 index bd72c5e..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs +++ /dev/null @@ -1,74 +0,0 @@ -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; - -namespace Jellyfin.Plugin.MovieNight.Services; - -/// -/// Periodically asks MovieNight to run its current Jellyfin sync. -/// -public sealed class MovieNightPeriodicSyncService : BackgroundService -{ - private readonly MovieNightSyncService _syncService; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - public MovieNightPeriodicSyncService( - MovieNightSyncService syncService, - ILogger logger) - { - _syncService = syncService; - _logger = logger; - } - - /// - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - while (!stoppingToken.IsCancellationRequested) - { - var delay = GetDelay(); - try - { - await Task.Delay(delay, stoppingToken).ConfigureAwait(false); - if (!ShouldRun()) - { - continue; - } - - await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false); - } - catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) - { - return; - } - catch (Exception ex) - { - _logger.LogWarning(ex, "MovieNight periodic sync failed"); - } - } - } - - private static bool ShouldRun() - { - var configuration = Plugin.Instance?.Configuration; - return configuration is { Enabled: true, EnablePeriodicSync: true }; - } - - private static TimeSpan GetDelay() - { - var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30; - return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440)); - } -} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs deleted file mode 100644 index 89a5f84..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs +++ /dev/null @@ -1,104 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Session; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Plugin.MovieNight.Services; - -/// -/// Subscribes to Jellyfin playback events and forwards thin payloads. -/// -public sealed class MovieNightPlaybackEventService : IHostedService -{ - private readonly ISessionManager _sessionManager; - private readonly MovieNightBackendClient _backendClient; - private readonly ILogger _logger; - - /// - /// Initializes a new instance of the class. - /// - /// Jellyfin session manager. - /// Backend client. - /// Logger. - public MovieNightPlaybackEventService( - ISessionManager sessionManager, - MovieNightBackendClient backendClient, - ILogger logger) - { - _sessionManager = sessionManager; - _backendClient = backendClient; - _logger = logger; - } - - /// - public Task StartAsync(CancellationToken cancellationToken) - { - _sessionManager.PlaybackStopped += OnPlaybackStopped; - return Task.CompletedTask; - } - - /// - public Task StopAsync(CancellationToken cancellationToken) - { - _sessionManager.PlaybackStopped -= OnPlaybackStopped; - return Task.CompletedTask; - } - - private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e) - { - if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true }) - { - return; - } - - if (!e.PlayedToCompletion) - { - return; - } - - var userId = e.Users?.FirstOrDefault()?.Id.ToString("N"); - var itemId = e.Item?.Id.ToString("N"); - if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId)) - { - return; - } - - var occurredAt = DateTimeOffset.UtcNow; - var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId) - ? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}" - : $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}"; - - var payload = new MovieNightEventPayload( - EventId: eventId, - EventType: "playback.stopped", - OccurredAt: occurredAt, - JellyfinUserId: userId, - ItemId: itemId, - PayloadVersion: 1, - Payload: new Dictionary - { - ["itemName"] = e.Item?.Name, - ["playSessionId"] = e.PlaySessionId, - ["positionTicks"] = e.PlaybackPositionTicks, - ["playedToCompletion"] = e.PlayedToCompletion - }); - - _ = Task.Run( - async () => - { - try - { - await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "MovieNight playback event push failed"); - } - }); - } -} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs deleted file mode 100644 index 4c1ec05..0000000 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs +++ /dev/null @@ -1,105 +0,0 @@ -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 config = Plugin.Instance?.Configuration; - var enabledLibraryIds = config?.EnabledLibraryIds ?? new List(); - - var query = new InternalItemsQuery - { - IncludeItemTypes = new[] { BaseItemKind.Movie }, - Recursive = true - }; - - if (enabledLibraryIds.Count > 0) - { - query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray(); - } - - var items = _libraryManager.GetItemList(query); - 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, - ["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary", - ["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"); - } -} diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md deleted file mode 100644 index 299ad21..0000000 --- a/plugins/jellyfin/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# MovieNight Jellyfin Plugin - -Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend. - -## Build - -```bash -cd plugins/jellyfin/Jellyfin.Plugin.MovieNight -dotnet publish -c Release -``` - -Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`. - -## Backend Contract Used - -Current implemented calls: - -- `POST /api/integrations/jellyfin/sync` -- `GET /api/integrations/jellyfin/sync-state` -- `POST /api/integrations/jellyfin/events` - -Event requests use JSON with: - -- `event_id` -- `event_type` -- `occurred_at` -- `jellyfin_user_id` -- `item_id` -- `payload_version` -- `payload` - -The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`. - -The config page test action posts a small synthetic event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure. - -Sync requests use JSON with: - -- `items` -- `items[].jellyfinItemId` -- `items[].title` -- `items[].description` -- `items[].year` -- `items[].genres` -- `items[].imdbId` -- `items[].userStates` -- `items[].userStates[].jellyfinUserId` -- `items[].userStates[].isViewed` -- `items[].userStates[].lastPlayedAt` diff --git a/plugins/jellyfin/build.yaml b/plugins/jellyfin/build.yaml deleted file mode 100644 index 3c846f3..0000000 --- a/plugins/jellyfin/build.yaml +++ /dev/null @@ -1,14 +0,0 @@ ---- -name: "MovieNight" -guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb" -version: 2 -targetAbi: "10.11.0.0" -framework: net9.0 -owner: "movienight" -overview: "Bridge Jellyfin events and sync triggers to MovieNight" -description: "Thin Jellyfin plugin for MovieNight backend integration" -category: "General" -artifacts: - - "Jellyfin.Plugin.MovieNight.dll" -changelog: |- - - Initial plugin implementation.