Merge pull request #47 from devitq/feat/implement-jellyfin-plugin-46

feat: implement Jellyfin plugin
This commit was merged in pull request #47.
This commit is contained in:
ITQ
2026-05-22 20:44:23 +03:00
committed by GitHub
29 changed files with 2552 additions and 52 deletions
+3 -3
View File
@@ -30,13 +30,13 @@ jobs:
- name: Set up Gradle - name: Set up Gradle
uses: gradle/actions/setup-gradle@v6 uses: gradle/actions/setup-gradle@v6
- name: Run CI quality gate
run: ./gradlew clean check bootJar --stacktrace --no-daemon
- name: Set artifact name - name: Set artifact name
id: meta id: meta
run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT" run: echo "artifact-name=build-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
- name: Run CI quality gate
run: ./gradlew clean check bootJar --stacktrace --no-daemon
- name: Upload build artifacts - name: Upload build artifacts
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
+54
View File
@@ -0,0 +1,54 @@
name: Jellyfin Plugin
run-name: "${{ github.ref_name }} - ${{ github.event_name }} by @${{ github.actor }}"
on:
push:
branches: [develop, main]
tags: ["v*"]
paths:
- ".github/workflows/jellyfin-plugin.yaml"
- "plugins/jellyfin/**"
pull_request:
branches: [develop, main]
paths:
- ".github/workflows/jellyfin-plugin.yaml"
- "plugins/jellyfin/**"
concurrency:
group: jellyfin-plugin-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Build Jellyfin Plugin
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
steps:
- name: Checkout source
uses: actions/checkout@v6
- name: Set up .NET 9
uses: actions/setup-dotnet@v5
with:
dotnet-version: "9.0.x"
- name: Restore plugin dependencies
run: dotnet restore plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj
- name: Publish plugin
run: |
dotnet publish \
plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj \
-c Release \
--no-restore \
-o artifacts/MovieNight
- name: Upload plugin artifact
uses: actions/upload-artifact@v7
with:
name: MovieNight
path: artifacts/MovieNight/*
retention-days: 7
if-no-files-found: error
+2
View File
@@ -0,0 +1,2 @@
**/bin/
**/obj/
@@ -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,456 @@
(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`));
const states = Array.isArray(state) ? state : [];
const latest = states
.map(s => s.lastSuccessfulSyncAt || s.lastSyncedAt)
.filter(Boolean)
.sort()
.pop();
if (latest) {
statusEl.innerText = `Last sync: ${new Date(latest).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,258 @@
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<IActionResult> SyncState(CancellationToken cancellationToken)
{
var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
return Content(body, "application/json");
}
/// <summary>
/// Gets recommendations for the current user.
/// </summary>
[HttpGet("Users/{userId}/Recommendations")]
[Authorize]
public async Task<IActionResult> GetRecommendations(
[FromRoute] string userId,
[FromQuery] string? contentType,
[FromQuery] string? mood,
[FromQuery] int limit = 10,
CancellationToken cancellationToken = default)
{
var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
return Content(body, "application/json");
}
/// <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<IActionResult> GetPreferences(
[FromRoute] string userId,
CancellationToken cancellationToken)
{
var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
return body is null ? NotFound() : Content(body, "application/json");
}
/// <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,323 @@
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: "plugin.test",
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)
{
userId = NormalizeJellyfinId(userId);
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/integrations/jellyfin/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)
{
userId = NormalizeJellyfinId(userId);
filmId = NormalizeJellyfinId(filmId);
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/ratings/items/{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)
{
userId = NormalizeJellyfinId(userId);
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/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)
{
userId = NormalizeJellyfinId(userId);
filmId = NormalizeJellyfinId(filmId);
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/library/items/{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)
{
userId = NormalizeJellyfinId(userId);
var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/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);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken)
{
userId = NormalizeJellyfinId(userId);
var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/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 string NormalizeJellyfinId(string value)
{
return Guid.TryParse(value, out var guid) ? guid.ToString("N") : value;
}
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,111 @@
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 syncUsers = users.Select(u => new
{
jellyfinUserId = u.Id.ToString("N"),
name = u.Username
}).ToList();
var syncItems = new List<object>();
foreach (var item in items)
{
if (item is not Movie movie) continue;
var jellyfinItemId = movie.Id.ToString("N");
var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name;
var itemData = new Dictionary<string, object?>
{
["jellyfinItemId"] = jellyfinItemId,
["title"] = title,
["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 { users = syncUsers, items = syncItems }, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("MovieNight library sync completed");
}
}
+64
View File
@@ -0,0 +1,64 @@
# 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`
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendations`
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/ratings/items/{jellyfin_item_id}`
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/library/items/{jellyfin_item_id}/viewed`
- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/preferences`
- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendation-onboarding`
Configure the backend with:
- `JELLYFIN_INTEGRATION_ENABLED=true`
- `JELLYFIN_PLUGIN_TOKEN=<same token configured in the plugin>`
- `JELLYFIN_WEB_URL=<browser URL of Jellyfin, used for recommendation watch links>`
`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`.
Optional backend-pull sync values:
- `JELLYFIN_BASE_URL=<backend-reachable Jellyfin server URL>`
- `JELLYFIN_API_KEY=<Jellyfin API key>`
The Jellyfin API key is only for backend-to-Jellyfin calls. The plugin token is a MovieNight shared secret for plugin-to-backend calls.
Configure the plugin with:
- Backend URL: MovieNight backend URL reachable from the Jellyfin server, for example `http://movienight-backend:8080`
- Plugin token: the exact `JELLYFIN_PLUGIN_TOKEN` value
- Enable MovieNight integration: checked
- Enable periodic backend sync: checked if the plugin should push library state on an interval
- Send playback stop events: checked if completed playback should mark films viewed in MovieNight
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`.
Sync requests push Jellyfin users, items, and per-user watched states to the backend. The backend creates MovieNight users for new Jellyfin users using their Jellyfin id as the stable mapping key, upserts films by `jellyfinItemId`, and uses the Jellyfin-facing endpoints above for UI actions so Jellyfin ids do not have to match MovieNight UUIDs. Run "Sync Library" once after installing/configuring the plugin so recommendations, rating, and viewed actions can resolve Jellyfin items.
The config page test action posts a small `plugin.test` event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure.
+14
View File
@@ -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.
@@ -26,9 +26,7 @@ class SecurityConfiguration(
.requestMatchers("/api/v1/docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**") .requestMatchers("/api/v1/docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**")
.permitAll() .permitAll()
.requestMatchers( .requestMatchers(
"/api/integrations/jellyfin/events", "/api/integrations/jellyfin/**",
"/api/integrations/jellyfin/sync",
"/api/integrations/jellyfin/sync-state",
).permitAll() ).permitAll()
.requestMatchers("/api/users/me") .requestMatchers("/api/users/me")
.authenticated() .authenticated()
@@ -3,7 +3,6 @@ package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
import com.project.movienight.application.ports.input.JellyfinEventUseCase import com.project.movienight.application.ports.input.JellyfinEventUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
import jakarta.validation.Valid import jakarta.validation.Valid
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
@@ -13,13 +12,12 @@ import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
@RestController @RestController
@RequestMapping("/api/integrations/jellyfin") @RequestMapping("/api/integrations/jellyfin")
class JellyfinEventsController( class JellyfinEventsController(
private val jellyfinEventUseCase: JellyfinEventUseCase, private val jellyfinEventUseCase: JellyfinEventUseCase,
private val properties: JellyfinIntegrationProperties, private val authenticator: JellyfinPluginAuthenticator,
) { ) {
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
@@ -29,15 +27,7 @@ class JellyfinEventsController(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody request: JellyfinEventRequest, @Valid @RequestBody request: JellyfinEventRequest,
) { ) {
if (!properties.enabled) { authenticator.authenticate(token)
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
}
if (properties.pluginToken.isNotBlank()) {
if (token == null || token != properties.pluginToken) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
}
}
log.debug( log.debug(
"Received Jellyfin event {} for user {} item {}", "Received Jellyfin event {} for user {} item {}",
@@ -0,0 +1,21 @@
package com.project.movienight.adapters.web
import com.project.movienight.config.JellyfinIntegrationProperties
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.server.ResponseStatusException
@Component
class JellyfinPluginAuthenticator(
private val properties: JellyfinIntegrationProperties,
) {
fun authenticate(token: String?) {
if (!properties.enabled) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
}
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
}
}
}
@@ -0,0 +1,227 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.RecommendationStyle
import com.project.movienight.domain.model.User
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.OffsetDateTime
import java.util.Locale
import java.util.UUID
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinPluginController(
private val authenticator: JellyfinPluginAuthenticator,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val getRecommendationsUseCase: GetRecommendationsUseCase,
private val filmRatingUseCase: FilmRatingUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
private val userPreferencesUseCase: UserPreferencesUseCase,
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
private val jellyfinProperties: JellyfinIntegrationProperties,
) {
@GetMapping("/users/{jellyfinUserId}/recommendations")
fun recommend(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResponse> {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return getRecommendationsUseCase
.recommend(
RecommendationQuery(
userId = user.id,
contentType = parseOptionalContentType(contentType),
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
}
@PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}")
fun rate(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@PathVariable jellyfinItemId: String,
@Valid @RequestBody request: RateFilmRequest,
): FilmRatingResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
val film = resolveFilm(jellyfinItemId)
return FilmRatingResponse.fromDomain(
filmRatingUseCase.rate(
RateFilmCommand(
userId = user.id,
filmId = film.id,
score = request.score,
note = request.note,
),
),
)
}
@GetMapping("/users/{jellyfinUserId}/ratings")
fun ratings(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
): List<FilmRatingResponse> {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) }
}
@PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed")
fun markViewed(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@PathVariable jellyfinItemId: String,
@RequestBody(required = false) request: JellyfinViewedRequest?,
): FilmLibraryEntryResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
val film = resolveFilm(jellyfinItemId)
return FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = user.id,
filmId = film.id,
watchedAt = request?.watchedAt?.toLocalDateTime(),
),
),
)
}
@GetMapping("/users/{jellyfinUserId}/preferences")
fun preferences(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
): UserPreferencesResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) }
?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found")
}
@PostMapping("/users/{jellyfinUserId}/recommendation-onboarding")
fun completeOnboarding(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@PathVariable jellyfinUserId: String,
@RequestBody request: RecommendationOnboardingRequest,
): RecommendationOnboardingResponse {
authenticator.authenticate(token)
val user = resolveOrCreateUser(jellyfinUserId)
return RecommendationOnboardingResponse.fromApplication(
completeRecommendationOnboardingUseCase.complete(
CompleteRecommendationOnboardingCommand(
userId = user.id,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() },
likedFilmIds = request.likedFilmIds,
dislikedFilmIds = request.dislikedFilmIds,
libraryFilmIds = request.libraryFilmIds,
watchedFilmIds = request.watchedFilmIds,
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
),
),
)
}
private fun resolveOrCreateUser(jellyfinUserId: String): User =
normalizeJellyfinId(jellyfinUserId).let { normalizedId ->
userRepository.findByJellyfinUserId(normalizedId)
?: userRepository.save(
User(
id = idGenerator.generateId(),
name = "Jellyfin User",
email = syntheticJellyfinEmail(normalizedId),
jellyfinUserId = normalizedId,
),
)
}
private fun resolveFilm(jellyfinItemId: String): Film =
normalizeJellyfinId(jellyfinItemId).let { normalizedId ->
filmRepository.findByJellyfinItemId(normalizedId)
?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId)
}
private fun buildWatchUrl(jellyfinItemId: String?): String? {
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
return null
}
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
return "$baseUrl/web/#/details?id=$encodedItemId"
}
private fun parseRecommendationStyle(value: String): RecommendationStyle =
runCatching { RecommendationStyle.valueOf(value.uppercase()) }
.getOrDefault(RecommendationStyle.BALANCED)
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
val safeId =
jellyfinUserId
.lowercase(Locale.getDefault())
.replace(Regex("[^a-z0-9._%+-]"), "-")
.take(240)
return "jellyfin-$safeId@movienight.local"
}
}
data class JellyfinViewedRequest(
val watchedAt: OffsetDateTime? = null,
)
@@ -1,10 +1,18 @@
package com.project.movienight.adapters.web package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
import com.project.movienight.application.ports.input.JellyfinSyncItemCommand
import com.project.movienight.application.ports.input.JellyfinSyncUseCase import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.application.ports.input.JellyfinSyncUserCommand
import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand
import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.JellyfinSyncSummary
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -12,10 +20,61 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/integrations/jellyfin") @RequestMapping("/api/integrations/jellyfin")
class JellyfinSyncController( class JellyfinSyncController(
private val jellyfinSyncUseCase: JellyfinSyncUseCase, private val jellyfinSyncUseCase: JellyfinSyncUseCase,
private val authenticator: JellyfinPluginAuthenticator,
) { ) {
@PostMapping("/sync") @PostMapping("/sync")
fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow() fun syncNow(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody(required = false) request: JellyfinSyncRequest?,
): JellyfinSyncSummary {
authenticator.authenticate(token)
return if (request == null) {
jellyfinSyncUseCase.syncNow()
} else {
jellyfinSyncUseCase.ingest(request.toCommand())
}
}
@GetMapping("/sync-state") @GetMapping("/sync-state")
fun syncState(): List<JellyfinSyncState> = jellyfinSyncUseCase.getSyncStates() fun syncState(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
): List<JellyfinSyncState> {
authenticator.authenticate(token)
return jellyfinSyncUseCase.getSyncStates()
}
private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand =
IngestJellyfinSyncCommand(
users =
users.map { user ->
JellyfinSyncUserCommand(
jellyfinUserId = user.jellyfinUserId,
name = user.name,
)
},
items =
items.map { item ->
JellyfinSyncItemCommand(
jellyfinItemId = item.jellyfinItemId,
title = item.title,
originalTitle = item.originalTitle,
description = item.description,
year = item.year,
genres = item.genres,
imdbId = item.imdbId,
tmdbId = item.tmdbId,
jellyfinLibraryId = item.jellyfinLibraryId,
userStates =
item.userStates.map { state ->
JellyfinSyncUserStateCommand(
jellyfinUserId = state.jellyfinUserId,
isViewed = state.isViewed,
playCount = state.playCount,
lastPlayedAt = state.lastPlayedAt,
userRating = state.userRating,
)
},
)
},
)
} }
@@ -0,0 +1,43 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import java.time.OffsetDateTime
data class JellyfinSyncRequest(
@field:Valid
val users: List<JellyfinSyncUserRequest> = emptyList(),
@field:Valid
val items: List<JellyfinSyncItemRequest> = emptyList(),
)
data class JellyfinSyncUserRequest(
@field:NotBlank
val jellyfinUserId: String,
val name: String? = null,
)
data class JellyfinSyncItemRequest(
@field:NotBlank
val jellyfinItemId: String,
@field:NotBlank
val title: String,
val originalTitle: String? = null,
val description: String? = null,
val year: Int? = null,
val genres: List<String> = emptyList(),
val imdbId: String? = null,
val tmdbId: String? = null,
val jellyfinLibraryId: String? = null,
@field:Valid
val userStates: List<JellyfinSyncUserStateRequest> = emptyList(),
)
data class JellyfinSyncUserStateRequest(
@field:NotBlank
val jellyfinUserId: String,
val isViewed: Boolean = false,
val playCount: Int = 0,
val lastPlayedAt: OffsetDateTime? = null,
val userRating: Double? = null,
)
@@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand(
interface JellyfinSyncUseCase { interface JellyfinSyncUseCase {
fun syncNow(): JellyfinSyncSummary fun syncNow(): JellyfinSyncSummary
fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary
fun getSyncStates(): List<JellyfinSyncState> fun getSyncStates(): List<JellyfinSyncState>
} }
data class IngestJellyfinSyncCommand(
val users: List<JellyfinSyncUserCommand> = emptyList(),
val items: List<JellyfinSyncItemCommand> = emptyList(),
)
data class JellyfinSyncUserCommand(
val jellyfinUserId: String,
val name: String?,
)
data class JellyfinSyncItemCommand(
val jellyfinItemId: String,
val title: String,
val originalTitle: String?,
val description: String?,
val year: Int?,
val genres: List<String>,
val imdbId: String?,
val tmdbId: String?,
val jellyfinLibraryId: String?,
val userStates: List<JellyfinSyncUserStateCommand>,
)
data class JellyfinSyncUserStateCommand(
val jellyfinUserId: String,
val isViewed: Boolean,
val playCount: Int,
val lastPlayedAt: OffsetDateTime?,
val userRating: Double?,
)
@@ -12,6 +12,7 @@ import com.project.movienight.application.ports.output.JellyfinEventStorePort
import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service @Service
class JellyfinEventService( class JellyfinEventService(
@@ -26,6 +27,8 @@ class JellyfinEventService(
@Transactional @Transactional
override fun handle(command: HandleJellyfinEventCommand) { override fun handle(command: HandleJellyfinEventCommand) {
val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId)
val jellyfinItemId = normalizeJellyfinId(command.itemId)
val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) }
val inserted = val inserted =
jellyfinEventStore.save( jellyfinEventStore.save(
@@ -34,8 +37,8 @@ class JellyfinEventService(
serverId = command.serverId, serverId = command.serverId,
eventType = command.eventType, eventType = command.eventType,
occurredAt = command.occurredAt, occurredAt = command.occurredAt,
jellyfinUserId = command.jellyfinUserId, jellyfinUserId = jellyfinUserId,
jellyfinItemId = command.itemId, jellyfinItemId = jellyfinItemId,
payload = payloadJson, payload = payloadJson,
), ),
) )
@@ -45,13 +48,13 @@ class JellyfinEventService(
try { try {
if (playbackEventTypes.contains(command.eventType)) { if (playbackEventTypes.contains(command.eventType)) {
val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId) val localUser = userRepository.findByJellyfinUserId(jellyfinUserId)
if (localUser == null) { if (localUser == null) {
businessMetricsService.recordJellyfinUnmappedUser() businessMetricsService.recordJellyfinUnmappedUser()
return return
} }
val film = filmRepository.findByJellyfinItemId(command.itemId) val film = filmRepository.findByJellyfinItemId(jellyfinItemId)
if (film == null) { if (film == null) {
businessMetricsService.recordBackendWriteFailure() businessMetricsService.recordBackendWriteFailure()
return return
@@ -72,4 +75,8 @@ class JellyfinEventService(
throw ex throw ex
} }
} }
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
} }
@@ -1,5 +1,6 @@
package com.project.movienight.application.services package com.project.movienight.application.services
import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand
import com.project.movienight.application.ports.input.JellyfinSyncUseCase import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
@@ -10,15 +11,18 @@ import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapsh
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.User
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Locale
import java.util.UUID import java.util.UUID
@Service @Service
@@ -56,6 +60,21 @@ class JellyfinSyncService(
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll() override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
override fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary {
if (!properties.enabled) {
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
}
return try {
ingestPluginSync(command)
} catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
businessMetricsService.recordJellyfinSyncFailure()
throw ex
}
}
private fun runSync(): JellyfinSyncSummary { private fun runSync(): JellyfinSyncSummary {
val startedAt = Instant.now() val startedAt = Instant.now()
val remoteUsers = jellyfinCatalog.fetchUsers() val remoteUsers = jellyfinCatalog.fetchUsers()
@@ -63,7 +82,7 @@ class JellyfinSyncService(
userRepository userRepository
.findAll() .findAll()
.mapNotNull { user -> .mapNotNull { user ->
user.jellyfinUserId?.let { it to user } user.jellyfinUserId?.let { normalizeJellyfinId(it) to user }
}.toMap() }.toMap()
var syncedUsers = 0 var syncedUsers = 0
@@ -71,7 +90,7 @@ class JellyfinSyncService(
var syncedItems = 0 var syncedItems = 0
remoteUsers.forEach { remoteUser -> remoteUsers.forEach { remoteUser ->
val localUser = localUsersByJellyfinId[remoteUser.id] val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)]
if (localUser == null) { if (localUser == null) {
skippedUsers += 1 skippedUsers += 1
return@forEach return@forEach
@@ -123,34 +142,39 @@ class JellyfinSyncService(
} }
private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film {
val normalizedItem =
item.copy(
jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId),
jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId),
)
val film = val film =
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy(
title = item.title, title = normalizedItem.title,
description = item.description, description = normalizedItem.description,
contentType = item.contentType, contentType = normalizedItem.contentType,
releaseYear = item.releaseYear, releaseYear = normalizedItem.releaseYear,
genres = item.genres, genres = normalizedItem.genres,
cast = item.cast, cast = normalizedItem.cast,
directors = item.directors, directors = normalizedItem.directors,
imdbRating = item.imdbRating, imdbRating = normalizedItem.imdbRating,
platformRating = item.platformRating, platformRating = normalizedItem.platformRating,
externalUrl = item.externalUrl, externalUrl = normalizedItem.externalUrl,
jellyfinItemId = item.jellyfinItemId, jellyfinItemId = normalizedItem.jellyfinItemId,
jellyfinLibraryId = item.jellyfinLibraryId, jellyfinLibraryId = normalizedItem.jellyfinLibraryId,
) ?: Film( ) ?: Film(
id = idGenerator.generateId(), id = idGenerator.generateId(),
title = item.title, title = normalizedItem.title,
description = item.description, description = normalizedItem.description,
contentType = item.contentType, contentType = normalizedItem.contentType,
releaseYear = item.releaseYear, releaseYear = normalizedItem.releaseYear,
genres = item.genres, genres = normalizedItem.genres,
cast = item.cast, cast = normalizedItem.cast,
directors = item.directors, directors = normalizedItem.directors,
imdbRating = item.imdbRating, imdbRating = normalizedItem.imdbRating,
platformRating = item.platformRating, platformRating = normalizedItem.platformRating,
externalUrl = item.externalUrl, externalUrl = normalizedItem.externalUrl,
jellyfinItemId = item.jellyfinItemId, jellyfinItemId = normalizedItem.jellyfinItemId,
jellyfinLibraryId = item.jellyfinLibraryId, jellyfinLibraryId = normalizedItem.jellyfinLibraryId,
) )
return filmRepository.save(film) return filmRepository.save(film)
@@ -176,4 +200,114 @@ class JellyfinSyncService(
), ),
) )
} }
private fun ingestPluginSync(command: IngestJellyfinSyncCommand): JellyfinSyncSummary {
val startedAt = Instant.now()
upsertPluginUsers(command)
val localUsersByJellyfinId =
userRepository
.findAll()
.mapNotNull { user -> user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } }
.toMap()
val skippedUserIds = mutableSetOf<String>()
val syncedCountsByUserId = mutableMapOf<UUID, Int>()
command.items.forEach { item ->
val savedFilm =
upsertFilm(
JellyfinLibraryItemSnapshot(
jellyfinItemId = item.jellyfinItemId,
title = item.title,
description = item.description ?: item.originalTitle ?: "",
contentType = ContentType.FILM,
releaseYear = item.year,
genres = item.genres,
cast = emptyList(),
directors = emptyList(),
platformRating = null,
imdbRating = null,
externalUrl = item.imdbId?.let { "https://www.imdb.com/title/$it/" },
jellyfinLibraryId = item.jellyfinLibraryId,
isPlayed = false,
),
)
item.userStates.forEach { state ->
val stateUserId = normalizeJellyfinId(state.jellyfinUserId)
val localUser = localUsersByJellyfinId[stateUserId]
if (localUser == null) {
skippedUserIds += stateUserId
return@forEach
}
syncedCountsByUserId[localUser.id] = syncedCountsByUserId.getOrDefault(localUser.id, 0) + 1
if (state.isViewed || state.playCount > 0) {
markFilmViewed(
userId = localUser.id,
filmId = savedFilm.id,
watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(),
)
}
}
}
val now = LocalDateTime.now()
syncedCountsByUserId.forEach { (userId, itemCount) ->
syncStateRepository.save(
JellyfinSyncState(
userId = userId,
lastSyncedAt = now,
lastSuccessfulSyncAt = now,
lastError = null,
syncedItemCount = itemCount,
),
)
}
val summary =
JellyfinSyncSummary(
syncedUsers = syncedCountsByUserId.size,
skippedUsers = skippedUserIds.size,
syncedItems = command.items.size,
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
)
businessMetricsService.recordJellyfinSync(summary)
return summary
}
private fun upsertPluginUsers(command: IngestJellyfinSyncCommand) {
command.users.forEach { remoteUser ->
val jellyfinUserId =
remoteUser.jellyfinUserId
.takeIf { it.isNotBlank() }
?.let(::normalizeJellyfinId)
?: return@forEach
if (userRepository.findByJellyfinUserId(jellyfinUserId) != null) {
return@forEach
}
userRepository.save(
User(
id = idGenerator.generateId(),
name = remoteUser.name?.takeIf { it.isNotBlank() } ?: "Jellyfin User",
email = syntheticJellyfinEmail(jellyfinUserId),
jellyfinUserId = jellyfinUserId,
),
)
}
}
private fun syntheticJellyfinEmail(jellyfinUserId: String): String {
val safeId =
jellyfinUserId
.lowercase(Locale.getDefault())
.replace(Regex("[^a-z0-9._%+-]"), "-")
.take(240)
return "jellyfin-$safeId@movienight.local"
}
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
} }
+2 -1
View File
@@ -97,12 +97,13 @@ info:
integrations: integrations:
jellyfin: jellyfin:
enabled: ${JELLYFIN_SYNC_ENABLED:false} enabled: ${JELLYFIN_INTEGRATION_ENABLED:false}
base-url: ${JELLYFIN_BASE_URL:} base-url: ${JELLYFIN_BASE_URL:}
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
api-key: ${JELLYFIN_API_KEY:} api-key: ${JELLYFIN_API_KEY:}
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
plugin-token: ${JELLYFIN_PLUGIN_TOKEN:}
services: services:
user: user:
@@ -0,0 +1,148 @@
package com.project.movienight.controllers
import com.fasterxml.jackson.databind.ObjectMapper
import org.hamcrest.Matchers.hasItem
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.http.MediaType
import org.springframework.test.web.servlet.MockMvc
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get
import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath
import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status
import org.springframework.transaction.annotation.Transactional
@SpringBootTest(
properties = [
"integrations.jellyfin.enabled=true",
"integrations.jellyfin.plugin-token=test-token",
"integrations.jellyfin.web-url=https://jellyfin.example.test",
],
)
@AutoConfigureMockMvc(addFilters = false)
@Transactional
class JellyfinPluginContractTest {
private val jellyfinUserId = "11111111111111111111111111111111"
private val dashedJellyfinUserId = "11111111-1111-1111-1111-111111111111"
private val jellyfinItemId = "22222222222222222222222222222222"
private val dashedJellyfinItemId = "22222222-2222-2222-2222-222222222222"
@Autowired
private lateinit var mockMvc: MockMvc
@Autowired
private lateinit var objectMapper: ObjectMapper
@Test
fun `plugin sync payload creates mapped user and film`() {
postSyncPayload()
val syncedRecommendationTitlePath = "$[?(@.jellyfinItemId == '$jellyfinItemId')].title"
val syncedRecommendationWatchUrlPath = "$[?(@.jellyfinItemId == '$jellyfinItemId')].watchUrl"
val expectedWatchUrl = "https://jellyfin.example.test/web/#/details?id=$jellyfinItemId"
mockMvc
.perform(
get("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendations")
.header("X-MovieNight-Plugin-Token", "test-token"),
).andExpect(status().isOk)
.andExpect(jsonPath("$[*].jellyfinItemId").value(hasItem(jellyfinItemId)))
.andExpect(jsonPath(syncedRecommendationTitlePath).value(hasItem("Jellyfin Contract Film")))
.andExpect(jsonPath(syncedRecommendationWatchUrlPath).value(hasItem(expectedWatchUrl)))
mockMvc
.perform(
post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/ratings/items/$dashedJellyfinItemId")
.header("X-MovieNight-Plugin-Token", "test-token")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"score":8,"note":"From Jellyfin UI"}"""),
).andExpect(status().isOk)
.andExpect(jsonPath("$.score").value(8))
val viewedPath =
"/api/integrations/jellyfin/users/$dashedJellyfinUserId/library/items/" +
"$dashedJellyfinItemId/viewed"
mockMvc
.perform(
post(viewedPath)
.header("X-MovieNight-Plugin-Token", "test-token")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"watchedAt":"2026-05-22T10:15:30Z"}"""),
).andExpect(status().isOk)
.andExpect(jsonPath("$.viewed").value(true))
mockMvc
.perform(
get("/api/integrations/jellyfin/sync-state")
.header("X-MovieNight-Plugin-Token", "test-token"),
).andExpect(status().isOk)
.andExpect(jsonPath("$[0].syncedItemCount").value(1))
}
@Test
fun `plugin token is required when configured`() {
mockMvc
.perform(
post("/api/integrations/jellyfin/sync")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(syncPayload())),
).andExpect(status().isUnauthorized)
}
@Test
fun `plugin onboarding can create user before first sync`() {
mockMvc
.perform(
post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendation-onboarding")
.header("X-MovieNight-Plugin-Token", "test-token")
.contentType(MediaType.APPLICATION_JSON)
.content("""{"weightedGenres":{"Drama":5},"contentTypes":["FILM"]}"""),
).andExpect(status().isOk)
.andExpect(jsonPath("$.userId").exists())
}
private fun postSyncPayload() {
mockMvc
.perform(
post("/api/integrations/jellyfin/sync")
.header("X-MovieNight-Plugin-Token", "test-token")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(syncPayload())),
).andExpect(status().isOk)
.andExpect(jsonPath("$.syncedUsers").value(1))
.andExpect(jsonPath("$.syncedItems").value(1))
}
private fun syncPayload(): Map<String, Any?> =
mapOf(
"users" to
listOf(
mapOf(
"jellyfinUserId" to jellyfinUserId,
"name" to "Jellyfin User",
),
),
"items" to
listOf(
mapOf(
"jellyfinItemId" to jellyfinItemId,
"title" to "Jellyfin Contract Film",
"description" to "Synced from plugin payload",
"year" to 2026,
"genres" to listOf("Drama"),
"imdbId" to "tt1234567",
"userStates" to
listOf(
mapOf(
"jellyfinUserId" to jellyfinUserId,
"isViewed" to false,
"playCount" to 0,
),
),
),
),
)
}