From c8acede152c55a94b0f8fc2054820adfdc9c6f68 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 17:09:04 +0300 Subject: [PATCH] feat(jellyfin): added jellyfin plugin --- plugins/jellyfin/.gitignore | 2 + .../Configuration/PluginConfiguration.cs | 50 ++ .../Configuration/config.js | 94 ++++ .../Configuration/configPage.html | 74 +++ .../Configuration/ui.js | 450 ++++++++++++++++++ .../Controllers/MovieNightController.cs | 255 ++++++++++ .../Jellyfin.Plugin.MovieNight.csproj | 36 ++ .../Jellyfin.Plugin.MovieNight/Plugin.cs | 73 +++ .../PluginServiceRegistrator.cs | 21 + .../Services/MovieNightBackendClient.cs | 309 ++++++++++++ .../Services/MovieNightEventPayload.cs | 31 ++ .../Services/MovieNightPeriodicSyncService.cs | 74 +++ .../MovieNightPlaybackEventService.cs | 104 ++++ .../Services/MovieNightSyncService.cs | 105 ++++ plugins/jellyfin/README.md | 34 ++ plugins/jellyfin/build.yaml | 14 + 16 files changed, 1726 insertions(+) create mode 100644 plugins/jellyfin/.gitignore create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs create mode 100644 plugins/jellyfin/README.md create mode 100644 plugins/jellyfin/build.yaml diff --git a/plugins/jellyfin/.gitignore b/plugins/jellyfin/.gitignore new file mode 100644 index 0000000..5967294 --- /dev/null +++ b/plugins/jellyfin/.gitignore @@ -0,0 +1,2 @@ +**/bin/ +**/obj/ diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..2e97960 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs @@ -0,0 +1,50 @@ +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 new file mode 100644 index 0000000..7e38881 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -0,0 +1,94 @@ +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 new file mode 100644 index 0000000..adb20db --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -0,0 +1,74 @@ + + + + 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 new file mode 100644 index 0000000..e02f661 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -0,0 +1,450 @@ +(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; + } + + async function injectUI() { + // Check for onboarding + await checkOnboarding(); + + // 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 showOnboardingDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Welcome to MovieNight!'); + dialog.style.minWidth = '450px'; + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +

Pick your preferences to get better recommendations.

+
+ +
+
+
+ +
+
+
+ +
+
+ `; + + const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"]; + const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"]; + const types = ["FILM", "SERIES"]; + + const selections = { genres: new Set(), eras: new Set(), types: new Set() }; + + const createChip = (text, container, type) => { + const chip = document.createElement('div'); + chip.innerText = text; + chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;'; + chip.onclick = () => { + if (selections[type].has(text)) { + selections[type].delete(text); + chip.style.backgroundColor = 'transparent'; + chip.style.borderColor = '#444'; + } else { + selections[type].add(text); + chip.style.backgroundColor = '#0064d2'; + chip.style.borderColor = '#0064d2'; + } + }; + container.appendChild(chip); + }; + + genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres')); + eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras')); + types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types')); + + const btnSave = document.createElement('button'); + btnSave.className = 'emby-button raised button-submit'; + btnSave.style.flex = '2'; + btnSave.style.backgroundColor = '#0064d2'; + btnSave.innerHTML = 'Save & Start'; + footer.insertBefore(btnSave, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnSave.onclick = async () => { + const payload = { + weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])), + eras: [...selections.eras], + contentTypes: [...selections.types] + }; + cleanup(); + await completeOnboarding(payload); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function checkOnboarding() { + if (window.movieNightOnboardingChecked) return; + window.movieNightOnboardingChecked = true; + + const userId = ApiClient.getCurrentUserId(); + if (!userId) return; + + try { + const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`)); + if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) { + showOnboardingDialog(); + } + } catch (err) { + if (err.status === 404) showOnboardingDialog(); + } + } + + async function completeOnboarding(payload) { + const userId = ApiClient.getCurrentUserId(); + try { + await ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`), + data: JSON.stringify(payload), + contentType: 'application/json' + }); + showMsg('Welcome! Your preferences have been saved.'); + } catch (err) { + showMsg('Failed to save onboarding preferences.'); + } + } + + 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 new file mode 100644 index 0000000..8c9ff73 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -0,0 +1,255 @@ +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(); + } + + /// + /// Gets user preferences. + /// + [HttpGet("Users/{userId}/Preferences")] + [Authorize] + public async Task> GetPreferences( + [FromRoute] string userId, + CancellationToken cancellationToken) + { + return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + } + + /// + /// Completes onboarding for a user. + /// + [HttpPost("Users/{userId}/Onboarding")] + [Authorize] + public async Task CompleteOnboarding( + [FromRoute] string userId, + [FromBody] object payload, + CancellationToken cancellationToken) + { + await _backendClient.CompleteOnboardingAsync(userId, payload, 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 new file mode 100644 index 0000000..a8e6ec0 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj @@ -0,0 +1,36 @@ + + + + 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 new file mode 100644 index 0000000..73a5718 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs @@ -0,0 +1,73 @@ +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 new file mode 100644 index 0000000..b4fc366 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs @@ -0,0 +1,21 @@ +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 new file mode 100644 index 0000000..deca123 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -0,0 +1,309 @@ +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; + } + + /// + /// Gets user preferences. + /// + public async Task GetPreferencesAsync(string userId, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences"); + if (request is null) return null; + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; + + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + return body; + } + + /// + /// Completes onboarding for a user. + /// + public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding"); + if (request is null) return; + + request.Content = JsonContent.Create(payload, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + } + + /// + /// 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 new file mode 100644 index 0000000..926199b --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs @@ -0,0 +1,31 @@ +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 new file mode 100644 index 0000000..bd72c5e --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs @@ -0,0 +1,74 @@ +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 new file mode 100644 index 0000000..89a5f84 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs @@ -0,0 +1,104 @@ +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 new file mode 100644 index 0000000..4c1ec05 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -0,0 +1,105 @@ +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 new file mode 100644 index 0000000..6a01e6d --- /dev/null +++ b/plugins/jellyfin/README.md @@ -0,0 +1,34 @@ +# 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. diff --git a/plugins/jellyfin/build.yaml b/plugins/jellyfin/build.yaml new file mode 100644 index 0000000..3c846f3 --- /dev/null +++ b/plugins/jellyfin/build.yaml @@ -0,0 +1,14 @@ +--- +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.