feat(jellyfin): added jellyfin plugin

This commit is contained in:
ITQ
2026-05-22 17:09:04 +03:00
parent 42008900f6
commit c8acede152
16 changed files with 1726 additions and 0 deletions
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.MovieNight.Configuration;
/// <summary>
/// MovieNight plugin settings persisted by Jellyfin.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether integration calls are enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the MovieNight backend base URL.
/// </summary>
public string BackendBaseUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the backend plugin token.
/// </summary>
public string ApiToken { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the periodic sync interval in minutes.
/// </summary>
public int SyncIntervalMinutes { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether playback stop events are pushed to MovieNight.
/// </summary>
public bool EnablePlaybackEvents { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether periodic backend sync is enabled.
/// </summary>
public bool EnablePeriodicSync { get; set; } = true;
/// <summary>
/// Gets or sets enabled Jellyfin library ids. Empty means all libraries.
/// </summary>
public List<string> EnabledLibraryIds { get; set; } = new();
/// <summary>
/// Gets or sets the path where .strm files will be created.
/// </summary>
public string StrmOutputPath { get; set; } = string.Empty;
}
@@ -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();
});
}
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<title>MovieNight</title>
</head>
<body>
<div
id="MovieNightConfigPage"
data-role="page"
class="page type-interior pluginConfigurationPage"
data-controller="__plugin/MovieNight.js">
<div data-role="content">
<div class="content-primary">
<form id="MovieNightConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="BackendBaseUrl">Backend URL</label>
<input is="emby-input" id="BackendBaseUrl" name="BackendBaseUrl" type="url" placeholder="http://localhost:8080" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ApiToken">Plugin token</label>
<input is="emby-input" id="ApiToken" name="ApiToken" type="password" autocomplete="new-password" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SyncIntervalMinutes">Sync interval minutes</label>
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="StrmOutputPath">STRM output path</label>
<input is="emby-input" id="StrmOutputPath" name="StrmOutputPath" type="text" placeholder="/data/movies/movienight" />
<div class="fieldDescription">Directory where .strm files will be created for new films.</div>
</div>
<label class="checkboxContainer">
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
<span>Enable MovieNight integration</span>
</label>
<label class="checkboxContainer">
<input id="EnablePeriodicSync" name="EnablePeriodicSync" type="checkbox" is="emby-checkbox" />
<span>Enable periodic backend sync</span>
</label>
<label class="checkboxContainer">
<input id="EnablePlaybackEvents" name="EnablePlaybackEvents" type="checkbox" is="emby-checkbox" />
<span>Send playback stop events</span>
</label>
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>Save</span>
</button>
</div>
<div>
<button is="emby-button" type="button" id="TestConnection" class="raised block">
<span>Test connection</span>
</button>
</div>
<div style="margin-top: 2em; padding: 1em; background: #333; border-radius: 4px;">
<h3>UI Integration</h3>
<p>To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin <strong>Custom JavaScript</strong> setting (Dashboard > General):</p>
<code id="UIScriptUrl" style="display: block; padding: 0.5em; background: #000; word-break: break-all;"></code>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
@@ -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 = `<span>${text}</span>`;
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 = `
<div class="detailButton-content">
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
</div>
`;
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 = `
<div class="sectionTitleContainer" style="display:flex; align-items:center; justify-content:space-between;">
<h2 class="sectionTitle">MovieNight</h2>
<span class="movieNightSyncStatus" style="font-size:0.8em; opacity:0.7;"></span>
</div>
<div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap; margin-top:0.5em;"></div>
`;
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 = `
<h2 style="margin-top:0; text-align:center; font-weight:400; color:white; opacity:1;">${title}</h2>
<div class="dialog-content" style="margin:1.5em 0; opacity:1;"></div>
<div class="dialog-footer" style="display:flex; gap:1em; opacity:1;">
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white !important; opacity:1;">Cancel</button>
</div>
`;
return dialog;
}
async function showRatingDialog(itemId) {
const overlay = createOverlay();
const dialog = createDialogBase('Rate on MovieNight');
const content = dialog.querySelector('.dialog-content');
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.8em;"></div>`;
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 = `
<div style="margin-bottom:1em;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Movie Title (Required)</label>
<input type="text" class="emby-input txtTitle" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="e.g. Inception">
</div>
<div style="display:flex; gap:1em; margin-bottom:1em;">
<div style="flex:1;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Year</label>
<input type="number" class="emby-input txtYear" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="2010">
</div>
<div style="flex:2;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">IMDb ID</label>
<input type="text" class="emby-input txtImdb" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="tt1375666">
</div>
</div>
<div>
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Stream URL (Optional)</label>
<input type="text" class="emby-input txtUrl" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="http://...">
</div>
`;
const btnAdd = document.createElement('button');
btnAdd.className = 'emby-button raised button-submit';
btnAdd.style.flex = '2';
btnAdd.style.backgroundColor = '#0064d2';
btnAdd.innerHTML = '<span>Add Film</span>';
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 = `
<p style="margin-bottom:1.5em; opacity:0.8; text-align:center;">Pick your preferences to get better recommendations.</p>
<div style="margin-bottom:1.5em;">
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Favorite Genres</label>
<div class="genre-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
<div style="margin-bottom:1.5em;">
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Preferred Eras</label>
<div class="era-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
<div>
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Content Types</label>
<div class="type-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
`;
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 = '<span>Save & Start</span>';
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();
})();
@@ -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;
/// <summary>
/// Admin endpoints for the MovieNight plugin.
/// </summary>
[ApiController]
[Route("MovieNight")]
public class MovieNightController : ControllerBase
{
private readonly MovieNightBackendClient _backendClient;
private readonly MovieNightSyncService _syncService;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
/// </summary>
public MovieNightController(
MovieNightBackendClient backendClient,
MovieNightSyncService syncService)
{
_backendClient = backendClient;
_syncService = syncService;
}
/// <summary>
/// Ping endpoint for connectivity checks.
/// </summary>
[HttpGet("Ping")]
public ActionResult Ping() => Ok("Pong");
/// <summary>
/// Returns plugin status.
/// </summary>
/// <returns>Status response.</returns>
[HttpGet("Status")]
[Authorize]
public ActionResult<MovieNightPluginStatus> 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);
}
/// <summary>
/// Tests backend connectivity.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connection result.</returns>
[HttpPost("TestConnection")]
[Authorize]
public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken)
{
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Triggers backend sync.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns>
[HttpPost("Sync")]
[Authorize]
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
{
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
return Ok("Sync triggered");
}
/// <summary>
/// Gets backend sync state.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns>
[HttpGet("SyncState")]
[Authorize]
public async Task<ActionResult<string>> SyncState(CancellationToken cancellationToken)
{
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets recommendations for the current user.
/// </summary>
[HttpGet("Users/{userId}/Recommendations")]
[Authorize]
public async Task<ActionResult<string>> 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);
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
[Authorize]
public async Task<ActionResult> 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();
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
[Authorize]
public async Task<ActionResult> MarkViewed(
[FromRoute] string userId,
[FromRoute] string filmId,
[FromBody] ViewedRequest request,
CancellationToken cancellationToken)
{
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Gets user preferences.
/// </summary>
[HttpGet("Users/{userId}/Preferences")]
[Authorize]
public async Task<ActionResult<string?>> GetPreferences(
[FromRoute] string userId,
CancellationToken cancellationToken)
{
return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
[HttpPost("Users/{userId}/Onboarding")]
[Authorize]
public async Task<ActionResult> CompleteOnboarding(
[FromRoute] string userId,
[FromBody] object payload,
CancellationToken cancellationToken)
{
await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// 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
/// </summary>
[HttpPost("Films")]
[Authorize]
public async Task<ActionResult> 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}");
}
}
}
/// <summary>
/// Create film request.
/// </summary>
public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
/// <summary>
/// Rating request.
/// </summary>
public sealed record RatingRequest(int Score, string? Note);
/// <summary>
/// Viewed request.
/// </summary>
public sealed record ViewedRequest(DateTimeOffset? WatchedAt);
/// <summary>
/// MovieNight plugin status response.
/// </summary>
/// <param name="Enabled">Whether integration is enabled.</param>
/// <param name="BackendBaseUrl">Backend base URL.</param>
/// <param name="PeriodicSyncEnabled">Whether periodic sync is enabled.</param>
/// <param name="PlaybackEventsEnabled">Whether playback events are enabled.</param>
/// <param name="SyncIntervalMinutes">Sync interval in minutes.</param>
public sealed record MovieNightPluginStatus(
bool Enabled,
string BackendBaseUrl,
bool PeriodicSyncEnabled,
bool PlaybackEventsEnabled,
int SyncIntervalMinutes);
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.MovieNight</RootNamespace>
<AssemblyName>Jellyfin.Plugin.MovieNight</AssemblyName>
<Version>1.0.0.1</Version>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Jellyfin.Common" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<None Remove="Configuration\config.js" />
<None Remove="Configuration\ui.js" />
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\config.js" />
<EmbeddedResource Include="Configuration\ui.js" />
</ItemGroup>
</Project>
@@ -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;
/// <summary>
/// MovieNight Jellyfin plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Application paths.</param>
/// <param name="xmlSerializer">XML serializer.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "MovieNight";
/// <inheritdoc />
public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb");
/// <inheritdoc />
public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend.";
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> 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)
}
];
}
}
@@ -0,0 +1,21 @@
using Jellyfin.Plugin.MovieNight.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.MovieNight;
/// <summary>
/// Registers MovieNight services with Jellyfin.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<MovieNightBackendClient>();
serviceCollection.AddSingleton<MovieNightSyncService>();
serviceCollection.AddHostedService<MovieNightPeriodicSyncService>();
serviceCollection.AddHostedService<MovieNightPlaybackEventService>();
}
}
@@ -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;
/// <summary>
/// Thin HTTP client for the MovieNight backend.
/// </summary>
public class MovieNightBackendClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ILogger<MovieNightBackendClient> _logger;
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightBackendClient"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public MovieNightBackendClient(ILogger<MovieNightBackendClient> logger)
{
_logger = logger;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
}
/// <summary>
/// Calls backend health.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connection result.</returns>
public async Task<MovieNightConnectionResult> 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<string, object?>
{
["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);
}
}
/// <summary>
/// Pushes library sync data to the backend.
/// </summary>
/// <param name="payload">Sync payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response body.</returns>
public async Task<string> 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;
}
/// <summary>
/// Gets recommendations for a user.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
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();
}
/// <summary>
/// Gets ratings for a user.
/// </summary>
public async Task<string> 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;
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
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();
}
/// <summary>
/// Reads backend sync state.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response body.</returns>
public async Task<string> 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;
}
/// <summary>
/// Gets user preferences.
/// </summary>
public async Task<string?> 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;
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
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();
}
/// <summary>
/// Pushes an event payload to the backend event endpoint.
/// </summary>
/// <param name="payload">Event payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task.</returns>
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;
}
}
/// <summary>
/// Backend connection result.
/// </summary>
/// <param name="Success">Whether the call succeeded.</param>
/// <param name="Message">Result message.</param>
public sealed record MovieNightConnectionResult(bool Success, string Message)
{
/// <summary>
/// Creates a successful result.
/// </summary>
/// <returns>Connection result.</returns>
public static MovieNightConnectionResult Ok() => new(true, "OK");
/// <summary>
/// Creates a failed result.
/// </summary>
/// <param name="message">Failure message.</param>
/// <returns>Connection result.</returns>
public static MovieNightConnectionResult Failed(string message) => new(false, message);
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Event payload sent to MovieNight.
/// </summary>
/// <param name="EventId">Idempotency key.</param>
/// <param name="EventType">Event type.</param>
/// <param name="OccurredAt">Event timestamp.</param>
/// <param name="JellyfinUserId">Jellyfin user id.</param>
/// <param name="ItemId">Jellyfin item id.</param>
/// <param name="PayloadVersion">Payload version.</param>
/// <param name="Payload">Extra event data.</param>
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<string, object?> Payload);
@@ -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;
/// <summary>
/// Periodically asks MovieNight to run its current Jellyfin sync.
/// </summary>
public sealed class MovieNightPeriodicSyncService : BackgroundService
{
private readonly MovieNightSyncService _syncService;
private readonly ILogger<MovieNightPeriodicSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPeriodicSyncService"/> class.
/// </summary>
public MovieNightPeriodicSyncService(
MovieNightSyncService syncService,
ILogger<MovieNightPeriodicSyncService> logger)
{
_syncService = syncService;
_logger = logger;
}
/// <inheritdoc />
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));
}
}
@@ -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;
/// <summary>
/// Subscribes to Jellyfin playback events and forwards thin payloads.
/// </summary>
public sealed class MovieNightPlaybackEventService : IHostedService
{
private readonly ISessionManager _sessionManager;
private readonly MovieNightBackendClient _backendClient;
private readonly ILogger<MovieNightPlaybackEventService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPlaybackEventService"/> class.
/// </summary>
/// <param name="sessionManager">Jellyfin session manager.</param>
/// <param name="backendClient">Backend client.</param>
/// <param name="logger">Logger.</param>
public MovieNightPlaybackEventService(
ISessionManager sessionManager,
MovieNightBackendClient backendClient,
ILogger<MovieNightPlaybackEventService> logger)
{
_sessionManager = sessionManager;
_backendClient = backendClient;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped += OnPlaybackStopped;
return Task.CompletedTask;
}
/// <inheritdoc />
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<string, object?>
{
["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");
}
});
}
}
@@ -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;
/// <summary>
/// Service for synchronizing the Jellyfin library with MovieNight.
/// </summary>
public class MovieNightSyncService
{
private readonly MovieNightBackendClient _backendClient;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
private readonly ILogger<MovieNightSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightSyncService"/> class.
/// </summary>
public MovieNightSyncService(
MovieNightBackendClient backendClient,
ILibraryManager libraryManager,
IUserManager userManager,
IUserDataManager userDataManager,
ILogger<MovieNightSyncService> logger)
{
_backendClient = backendClient;
_libraryManager = libraryManager;
_userManager = userManager;
_userDataManager = userDataManager;
_logger = logger;
}
/// <summary>
/// Performs a full library sync.
/// </summary>
public async Task PerformSyncAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting MovieNight library sync");
var config = Plugin.Instance?.Configuration;
var enabledLibraryIds = config?.EnabledLibraryIds ?? new List<string>();
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<object>();
foreach (var item in items)
{
if (item is not Movie movie) continue;
var jellyfinItemId = movie.Id.ToString("N");
var itemData = new Dictionary<string, object?>
{
["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");
}
}