diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 8cd8d59..e7960c4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -30,13 +30,13 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@v6 - - name: Run CI quality gate - run: ./gradlew clean check bootJar --stacktrace --no-daemon - - name: Set artifact name id: meta 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 if: always() uses: actions/upload-artifact@v7 diff --git a/.github/workflows/jellyfin-plugin.yaml b/.github/workflows/jellyfin-plugin.yaml new file mode 100644 index 0000000..874d201 --- /dev/null +++ b/.github/workflows/jellyfin-plugin.yaml @@ -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 diff --git a/plugins/jellyfin/.gitignore b/plugins/jellyfin/.gitignore new file mode 100644 index 0000000..5967294 --- /dev/null +++ b/plugins/jellyfin/.gitignore @@ -0,0 +1,2 @@ +**/bin/ +**/obj/ diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..2e97960 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.MovieNight.Configuration; + +/// +/// MovieNight plugin settings persisted by Jellyfin. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets a value indicating whether integration calls are enabled. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the MovieNight backend base URL. + /// + public string BackendBaseUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the backend plugin token. + /// + public string ApiToken { get; set; } = string.Empty; + + /// + /// Gets or sets the periodic sync interval in minutes. + /// + public int SyncIntervalMinutes { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether playback stop events are pushed to MovieNight. + /// + public bool EnablePlaybackEvents { get; set; } = true; + + /// + /// Gets or sets a value indicating whether periodic backend sync is enabled. + /// + public bool EnablePeriodicSync { get; set; } = true; + + /// + /// Gets or sets enabled Jellyfin library ids. Empty means all libraries. + /// + public List EnabledLibraryIds { get; set; } = new(); + + /// + /// Gets or sets the path where .strm files will be created. + /// + public string StrmOutputPath { get; set; } = string.Empty; +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js new file mode 100644 index 0000000..7e38881 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -0,0 +1,94 @@ +const movieNightConfigPage = { + pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb", + + loadConfiguration(view) { + Dashboard.showLoadingMsg(); + + return ApiClient.getPluginConfiguration(this.pluginId) + .then((config) => { + view.querySelector("#BackendBaseUrl").value = + config.BackendBaseUrl || ""; + view.querySelector("#ApiToken").value = config.ApiToken || ""; + view.querySelector("#SyncIntervalMinutes").value = + config.SyncIntervalMinutes || 30; + view.querySelector("#StrmOutputPath").value = + config.StrmOutputPath || ""; + view.querySelector("#Enabled").checked = config.Enabled || false; + view.querySelector("#EnablePeriodicSync").checked = + config.EnablePeriodicSync !== false; + view.querySelector("#EnablePlaybackEvents").checked = + config.EnablePlaybackEvents !== false; + + const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", { + name: "MovieNight.ui.js", + }); + view.querySelector("#UIScriptUrl").innerText = uiScriptUrl; + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, + + saveConfiguration(view) { + const form = view.querySelector("#MovieNightConfigForm"); + Dashboard.showLoadingMsg(); + + return ApiClient.getPluginConfiguration(this.pluginId) + .then((config) => { + config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value; + config.ApiToken = form.querySelector("#ApiToken").value; + config.SyncIntervalMinutes = parseInt( + form.querySelector("#SyncIntervalMinutes").value || "30", + 10, + ); + config.StrmOutputPath = form.querySelector("#StrmOutputPath").value; + config.Enabled = form.querySelector("#Enabled").checked; + config.EnablePeriodicSync = + form.querySelector("#EnablePeriodicSync").checked; + config.EnablePlaybackEvents = + form.querySelector("#EnablePlaybackEvents").checked; + + return ApiClient.updatePluginConfiguration(this.pluginId, config); + }) + .then((result) => { + Dashboard.processPluginConfigurationUpdateResult(result); + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, + + testConnection() { + Dashboard.showLoadingMsg(); + + return ApiClient.ajax({ + type: "POST", + url: ApiClient.getUrl("MovieNight/TestConnection"), + }) + .then((result) => { + Dashboard.alert((result && result.message) || "OK"); + }) + .catch(() => { + Dashboard.alert("MovieNight connection test failed"); + }) + .finally(() => { + Dashboard.hideLoadingMsg(); + }); + }, +}; + +export default function (view) { + movieNightConfigPage.loadConfiguration(view); + + view + .querySelector("#MovieNightConfigForm") + .addEventListener("submit", (event) => { + event.preventDefault(); + movieNightConfigPage.saveConfiguration(view); + }); + + view.querySelector("#TestConnection").addEventListener("click", (event) => { + event.preventDefault(); + movieNightConfigPage.testConnection(); + }); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html new file mode 100644 index 0000000..adb20db --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -0,0 +1,74 @@ + + + + MovieNight + + +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
Directory where .strm files will be created for new films.
+
+ + + + + + + +
+ +
+ +
+ +
+ +
+

UI Integration

+

To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin Custom JavaScript setting (Dashboard > General):

+ +
+
+
+
+
+ + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js new file mode 100644 index 0000000..022ad33 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -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 = `${text}`; + btn.onclick = onClick; + return btn; + } + + function createIconButton(icon, title, className, onClick) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.is = 'emby-button'; + btn.className = `button-flat detailButton emby-button ${className}`; + btn.title = title; + btn.innerHTML = ` +
+ +
+ `; + btn.onclick = onClick; + return btn; + } + + async function injectUI() { + // Check for onboarding + await checkOnboarding(); + + // 1. Item Detail Page + const detailButtons = document.querySelector('.mainDetailButtons'); + if (detailButtons) { + const itemId = getItemIdFromUrl(); + if (itemId) { + // MovieNight Rating + if (!document.querySelector('.btnMovieNightRate')) { + const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => { + e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId); + }); + insertInDetailRow(detailButtons, rateBtn); + } + // Mark Viewed in MovieNight + if (!document.querySelector('.btnMovieNightMarkViewed')) { + const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => { + e.preventDefault(); e.stopPropagation(); submitViewed(itemId); + }); + insertInDetailRow(detailButtons, viewedBtn); + } + } + } + + // 2. Library Pages - Add text buttons to toolbar + const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x'); + if (toolBar && !document.querySelector('.btnMovieNightRecommend')) { + toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => { + e.preventDefault(); showRecommendation(); + })); + toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => { + e.preventDefault(); showAddMovieDialog(); + })); + } + + // 3. Home Page - Prepend a MovieNight section + const homeSections = document.querySelector('.sections.homeSectionsContainer'); + if (homeSections && !document.querySelector('.movieNightHomeButtons')) { + const section = document.createElement('div'); + section.className = 'verticalSection movieNightHomeButtons'; + section.style.padding = '0 var(--sidePadding)'; + section.innerHTML = ` +
+

MovieNight

+ +
+
+ `; + const btnContainer = section.querySelector('.movieNightBtnContainer'); + btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation)); + btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog)); + btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync)); + + homeSections.insertBefore(section, homeSections.firstChild); + updateSyncStatus(); + } + } + + function insertInDetailRow(container, btn) { + const moreBtn = container.querySelector('.btnMoreCommands'); + if (moreBtn) container.insertBefore(btn, moreBtn); + else container.appendChild(btn); + } + + function getItemIdFromUrl() { + const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search; + const params = new URLSearchParams(queryString); + return params.get('id') || params.get('itemId'); + } + + function createOverlay() { + const overlay = document.createElement('div'); + overlay.className = 'dialogBackdrop dialogBackdropOpened'; + overlay.style.zIndex = '99998'; + overlay.style.backgroundColor = 'rgba(0,0,0,0.7)'; + overlay.style.position = 'fixed'; + overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0'; + overlay.style.backdropFilter = 'blur(8px)'; + overlay.style.opacity = '1'; + return overlay; + } + + function createDialogBase(title) { + const dialog = document.createElement('div'); + dialog.className = 'dialog'; + dialog.style.position = 'fixed'; + dialog.style.top = '50%'; dialog.style.left = '50%'; + dialog.style.transform = 'translate(-50%, -50%)'; + dialog.style.zIndex = '99999'; + dialog.style.padding = '2.5em'; + dialog.style.minWidth = '350px'; + dialog.style.backgroundColor = '#1a1a1a'; + dialog.style.borderRadius = '1.5em'; + dialog.style.color = 'white'; + dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)'; + dialog.style.border = '1px solid #444'; + dialog.style.opacity = '1'; + + dialog.innerHTML = ` +

${title}

+
+ + `; + return dialog; + } + + async function showRatingDialog(itemId) { + const overlay = createOverlay(); + const dialog = createDialogBase('Rate on MovieNight'); + const content = dialog.querySelector('.dialog-content'); + + content.innerHTML = `
`; + const grid = content.querySelector('.rating-grid'); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + for (let i = 1; i <= 10; i++) { + const btn = document.createElement('button'); + btn.type = 'button'; btn.is = 'emby-button'; + btn.className = 'emby-button raised'; + btn.innerText = i; + btn.style.padding = '0.8em 0'; + btn.style.textAlign = 'center'; + btn.style.display = 'flex'; + btn.style.alignItems = 'center'; + btn.style.justifyContent = 'center'; + btn.style.fontSize = '1.2em'; + btn.onclick = async () => { cleanup(); await submitRating(itemId, i); }; + grid.appendChild(btn); + } + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function showAddMovieDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Add Movie (STRM)'); + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ `; + + const btnAdd = document.createElement('button'); + btnAdd.className = 'emby-button raised button-submit'; + btnAdd.style.flex = '2'; + btnAdd.style.backgroundColor = '#0064d2'; + btnAdd.innerHTML = 'Add Film'; + footer.insertBefore(btnAdd, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnAdd.onclick = async () => { + const title = dialog.querySelector('.txtTitle').value; + const year = dialog.querySelector('.txtYear').value; + const imdbId = dialog.querySelector('.txtImdb').value; + const url = dialog.querySelector('.txtUrl').value; + if (!title) return; + cleanup(); + await addMovie(title, url, year, imdbId); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + dialog.querySelector('.txtTitle').focus(); + } + + async function showOnboardingDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Welcome to MovieNight!'); + dialog.style.minWidth = '450px'; + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +

Pick your preferences to get better recommendations.

+
+ +
+
+
+ +
+
+
+ +
+
+ `; + + const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"]; + const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"]; + const types = ["FILM", "SERIES"]; + + const selections = { genres: new Set(), eras: new Set(), types: new Set() }; + + const createChip = (text, container, type) => { + const chip = document.createElement('div'); + chip.innerText = text; + chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;'; + chip.onclick = () => { + if (selections[type].has(text)) { + selections[type].delete(text); + chip.style.backgroundColor = 'transparent'; + chip.style.borderColor = '#444'; + } else { + selections[type].add(text); + chip.style.backgroundColor = '#0064d2'; + chip.style.borderColor = '#0064d2'; + } + }; + container.appendChild(chip); + }; + + genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres')); + eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras')); + types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types')); + + const btnSave = document.createElement('button'); + btnSave.className = 'emby-button raised button-submit'; + btnSave.style.flex = '2'; + btnSave.style.backgroundColor = '#0064d2'; + btnSave.innerHTML = 'Save & Start'; + footer.insertBefore(btnSave, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnSave.onclick = async () => { + const payload = { + weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])), + eras: [...selections.eras], + contentTypes: [...selections.types] + }; + cleanup(); + await completeOnboarding(payload); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function checkOnboarding() { + if (window.movieNightOnboardingChecked) return; + window.movieNightOnboardingChecked = true; + + const userId = ApiClient.getCurrentUserId(); + if (!userId) return; + + try { + const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`)); + if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) { + showOnboardingDialog(); + } + } catch (err) { + if (err.status === 404) showOnboardingDialog(); + } + } + + async function completeOnboarding(payload) { + const userId = ApiClient.getCurrentUserId(); + try { + await ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`), + data: JSON.stringify(payload), + contentType: 'application/json' + }); + showMsg('Welcome! Your preferences have been saved.'); + } catch (err) { + showMsg('Failed to save onboarding preferences.'); + } + } + + async function showRecommendation() { + const userId = ApiClient.getCurrentUserId(); + try { + const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`)); + const recommendations = typeof response === 'string' ? JSON.parse(response) : response; + + if (recommendations && recommendations.length > 0) { + const rec = recommendations[0]; + const film = rec.film || rec; + showMsg({ + title: 'MovieNight Recommendation', + text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}` + }); + } else { + showMsg('No recommendations found at the moment.'); + } + } catch (err) { + console.error('Failed to get recommendations', err); + showMsg('Failed to get recommendations. Check your API token and MovieNight status.'); + } + } + + async function addMovie(title, url, year, imdbId) { + try { + const data = { title, url }; + if (year) data.year = parseInt(year); + if (imdbId) data.imdbId = imdbId; + + await ApiClient.ajax({ + type: 'POST', + url: ApiClient.getUrl(`MovieNight/Films`), + data: JSON.stringify(data), + contentType: 'application/json' + }); + showMsg(`STRM file created for "${title}". Refresh your library to see it.`); + } catch (err) { + console.error('Failed to create movie', err); + showMsg('Failed to create movie. Ensure STRM output path is configured.'); + } + } + + async function triggerSync() { + try { + await ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl(`MovieNight/Sync`) }); + showMsg('Library sync triggered!'); + setTimeout(updateSyncStatus, 2000); + } catch (err) { + showMsg('Failed to trigger sync.'); + } + } + + async function updateSyncStatus() { + const statusEl = document.querySelector('.movieNightSyncStatus'); + if (!statusEl) return; + try { + const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`)); + 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(); +})(); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs new file mode 100644 index 0000000..7d94e7c --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -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; + +/// +/// Admin endpoints for the MovieNight plugin. +/// +[ApiController] +[Route("MovieNight")] +public class MovieNightController : ControllerBase +{ + private readonly MovieNightBackendClient _backendClient; + private readonly MovieNightSyncService _syncService; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightController( + MovieNightBackendClient backendClient, + MovieNightSyncService syncService) + { + _backendClient = backendClient; + _syncService = syncService; + } + + /// + /// Ping endpoint for connectivity checks. + /// + [HttpGet("Ping")] + public ActionResult Ping() => Ok("Pong"); + + /// + /// Returns plugin status. + /// + /// Status response. + [HttpGet("Status")] + [Authorize] + public ActionResult GetStatus() + { + var configuration = Plugin.Instance?.Configuration; + return new MovieNightPluginStatus( + Enabled: configuration?.Enabled ?? false, + BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty, + PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false, + PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false, + SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30); + } + + /// + /// Tests backend connectivity. + /// + /// Cancellation token. + /// Connection result. + [HttpPost("TestConnection")] + [Authorize] + public async Task> TestConnection(CancellationToken cancellationToken) + { + return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Triggers backend sync. + /// + /// Cancellation token. + /// Backend response. + [HttpPost("Sync")] + [Authorize] + public async Task> Sync(CancellationToken cancellationToken) + { + await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); + return Ok("Sync triggered"); + } + + /// + /// Gets backend sync state. + /// + /// Cancellation token. + /// Backend response. + [HttpGet("SyncState")] + [Authorize] + public async Task SyncState(CancellationToken cancellationToken) + { + var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); + } + + /// + /// Gets recommendations for the current user. + /// + [HttpGet("Users/{userId}/Recommendations")] + [Authorize] + public async Task GetRecommendations( + [FromRoute] string userId, + [FromQuery] string? contentType, + [FromQuery] string? mood, + [FromQuery] int limit = 10, + CancellationToken cancellationToken = default) + { + var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); + } + + /// + /// Posts a rating for a film. + /// + [HttpPost("Users/{userId}/Ratings/Films/{filmId}")] + [Authorize] + public async Task PostRating( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] RatingRequest request, + CancellationToken cancellationToken) + { + await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Marks a film as viewed. + /// + [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")] + [Authorize] + public async Task MarkViewed( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] ViewedRequest request, + CancellationToken cancellationToken) + { + await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Gets user preferences. + /// + [HttpGet("Users/{userId}/Preferences")] + [Authorize] + public async Task GetPreferences( + [FromRoute] string userId, + CancellationToken cancellationToken) + { + var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + return body is null ? NotFound() : Content(body, "application/json"); + } + + /// + /// Completes onboarding for a user. + /// + [HttpPost("Users/{userId}/Onboarding")] + [Authorize] + public async Task CompleteOnboarding( + [FromRoute] string userId, + [FromBody] object payload, + CancellationToken cancellationToken) + { + await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Creates a new film by generating a .strm file in a folder-per-movie structure. + /// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm + /// + [HttpPost("Films")] + [Authorize] + public async Task CreateFilm([FromBody] CreateFilmRequest request) + { + var config = Plugin.Instance?.Configuration; + if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath)) + { + return BadRequest("STRM output path is not configured."); + } + + if (string.IsNullOrWhiteSpace(request.Title)) + { + return BadRequest("Movie title is required."); + } + + try + { + // Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]" + var folderName = request.Title.Trim(); + if (request.Year.HasValue) + { + folderName += $" ({request.Year})"; + } + if (!string.IsNullOrWhiteSpace(request.ImdbId)) + { + var ttId = request.ImdbId.Trim().ToLowerInvariant(); + if (!ttId.StartsWith("tt")) ttId = "tt" + ttId; + folderName += $" [imdbid-{ttId}]"; + } + + // Sanitize for file system + var invalidChars = Path.GetInvalidFileNameChars(); + var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray()); + + var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName); + if (!Directory.Exists(movieDirectory)) + { + Directory.CreateDirectory(movieDirectory); + } + + var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm"); + + var strmContent = string.IsNullOrWhiteSpace(request.Url) + ? "http://placeholder.url/upload_me_later" + : request.Url.Trim(); + + await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false); + + return Ok(new { FilePath = filePath, FolderName = safeFolderName }); + } + catch (Exception ex) + { + return StatusCode(500, $"Failed to create film: {ex.Message}"); + } + } +} + +/// +/// Create film request. +/// +public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId); + +/// +/// Rating request. +/// +public sealed record RatingRequest(int Score, string? Note); + +/// +/// Viewed request. +/// +public sealed record ViewedRequest(DateTimeOffset? WatchedAt); + +/// +/// MovieNight plugin status response. +/// +/// Whether integration is enabled. +/// Backend base URL. +/// Whether periodic sync is enabled. +/// Whether playback events are enabled. +/// Sync interval in minutes. +public sealed record MovieNightPluginStatus( + bool Enabled, + string BackendBaseUrl, + bool PeriodicSyncEnabled, + bool PlaybackEventsEnabled, + int SyncIntervalMinutes); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj new file mode 100644 index 0000000..a8e6ec0 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + Jellyfin.Plugin.MovieNight + Jellyfin.Plugin.MovieNight + 1.0.0.1 + GPL-3.0-or-later + enable + true + false + + + + + + runtime + + + runtime + + + runtime + + + + + + + + + + + + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs new file mode 100644 index 0000000..73a5718 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs @@ -0,0 +1,73 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Jellyfin.Plugin.MovieNight.Configuration; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Plugins; +using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Serialization; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// MovieNight Jellyfin plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Application paths. + /// XML serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "MovieNight"; + + /// + public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb"); + + /// + public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend."; + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable GetPages() + { + return + [ + new PluginPageInfo + { + Name = Name, + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.configPage.html", + GetType().Namespace) + }, + new PluginPageInfo + { + Name = Name + ".js", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.config.js", + GetType().Namespace) + }, + new PluginPageInfo + { + Name = Name + ".ui.js", + EmbeddedResourcePath = string.Format( + CultureInfo.InvariantCulture, + "{0}.Configuration.ui.js", + GetType().Namespace) + } + ]; + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs new file mode 100644 index 0000000..b4fc366 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs @@ -0,0 +1,21 @@ +using Jellyfin.Plugin.MovieNight.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// Registers MovieNight services with Jellyfin. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + serviceCollection.AddHostedService(); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs new file mode 100644 index 0000000..90381b9 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -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; + +/// +/// Thin HTTP client for the MovieNight backend. +/// +public class MovieNightBackendClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public MovieNightBackendClient(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + } + + /// + /// Calls backend health. + /// + /// Cancellation token. + /// Connection result. + public async Task TestConnectionAsync(CancellationToken cancellationToken) + { + var payload = new MovieNightEventPayload( + EventId: $"plugin-test:{Guid.NewGuid():N}", + EventType: "plugin.test", + OccurredAt: DateTimeOffset.UtcNow, + JellyfinUserId: "movienight-plugin-test-user", + ItemId: "movienight-plugin-test-item", + PayloadVersion: 1, + Payload: new Dictionary + { + ["source"] = "config-test" + }); + var request = CreateEventRequest(payload); + if (request is null) + { + return MovieNightConnectionResult.Failed("Plugin is not configured."); + } + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + return response.IsSuccessStatusCode + ? MovieNightConnectionResult.Ok() + : MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}."); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _logger.LogWarning(ex, "MovieNight connection test failed"); + return MovieNightConnectionResult.Failed(ex.Message); + } + } + + /// + /// Pushes library sync data to the backend. + /// + /// Sync payload. + /// Cancellation token. + /// Backend response body. + public async Task SyncAsync(object payload, CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync"); + if (request is null) + { + return "Plugin is not configured."; + } + + request.Content = JsonContent.Create(payload, options: JsonOptions); + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Gets recommendations for a user. + /// + public async Task GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken) + { + 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; + } + + /// + /// Posts a rating for a film. + /// + 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(); + } + + /// + /// Gets ratings for a user. + /// + public async Task 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; + } + + /// + /// Marks a film as viewed. + /// + 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(); + } + + /// + /// Reads backend sync state. + /// + /// Cancellation token. + /// Backend response body. + public async Task GetSyncStateAsync(CancellationToken cancellationToken) + { + var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state"); + if (request is null) + { + return "Plugin is not configured."; + } + + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); + return body; + } + + /// + /// Gets user preferences. + /// + public async Task GetPreferencesAsync(string userId, CancellationToken cancellationToken) + { + 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; + } + + /// + /// Completes onboarding for a user. + /// + 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(); + } + + /// + /// Pushes an event payload to the backend event endpoint. + /// + /// Event payload. + /// Cancellation token. + /// A task. + public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken) + { + for (var attempt = 1; attempt <= 3; attempt++) + { + var request = CreateEventRequest(payload); + if (request is null) + { + return; + } + + try + { + using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); + if (response.IsSuccessStatusCode) + { + return; + } + + if ((int)response.StatusCode == 401) + { + _logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized"); + return; + } + + _logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode); + } + catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException) + { + _logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt); + } + + if (attempt < 3) + { + await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false); + } + } + } + + private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload) + { + var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events"); + if (request is null) + { + return null; + } + + request.Content = JsonContent.Create(payload, options: JsonOptions); + return request; + } + + private static string? GetBaseUrl() + { + var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim(); + return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); + } + + private static 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; + } +} + +/// +/// Backend connection result. +/// +/// Whether the call succeeded. +/// Result message. +public sealed record MovieNightConnectionResult(bool Success, string Message) +{ + /// + /// Creates a successful result. + /// + /// Connection result. + public static MovieNightConnectionResult Ok() => new(true, "OK"); + + /// + /// Creates a failed result. + /// + /// Failure message. + /// Connection result. + public static MovieNightConnectionResult Failed(string message) => new(false, message); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs new file mode 100644 index 0000000..926199b --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Event payload sent to MovieNight. +/// +/// Idempotency key. +/// Event type. +/// Event timestamp. +/// Jellyfin user id. +/// Jellyfin item id. +/// Payload version. +/// Extra event data. +public sealed record MovieNightEventPayload( + [property: JsonPropertyName("event_id")] + string EventId, + [property: JsonPropertyName("event_type")] + string EventType, + [property: JsonPropertyName("occurred_at")] + DateTimeOffset OccurredAt, + [property: JsonPropertyName("jellyfin_user_id")] + string JellyfinUserId, + [property: JsonPropertyName("item_id")] + string ItemId, + [property: JsonPropertyName("payload_version")] + int PayloadVersion, + [property: JsonPropertyName("payload")] + IReadOnlyDictionary Payload); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs new file mode 100644 index 0000000..bd72c5e --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Model.Entities; +using MediaBrowser.Model.Querying; +using Jellyfin.Data.Enums; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Periodically asks MovieNight to run its current Jellyfin sync. +/// +public sealed class MovieNightPeriodicSyncService : BackgroundService +{ + private readonly MovieNightSyncService _syncService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightPeriodicSyncService( + MovieNightSyncService syncService, + ILogger logger) + { + _syncService = syncService; + _logger = logger; + } + + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + var delay = GetDelay(); + try + { + await Task.Delay(delay, stoppingToken).ConfigureAwait(false); + if (!ShouldRun()) + { + continue; + } + + await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "MovieNight periodic sync failed"); + } + } + } + + private static bool ShouldRun() + { + var configuration = Plugin.Instance?.Configuration; + return configuration is { Enabled: true, EnablePeriodicSync: true }; + } + + private static TimeSpan GetDelay() + { + var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30; + return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440)); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs new file mode 100644 index 0000000..89a5f84 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Session; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Subscribes to Jellyfin playback events and forwards thin payloads. +/// +public sealed class MovieNightPlaybackEventService : IHostedService +{ + private readonly ISessionManager _sessionManager; + private readonly MovieNightBackendClient _backendClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Jellyfin session manager. + /// Backend client. + /// Logger. + public MovieNightPlaybackEventService( + ISessionManager sessionManager, + MovieNightBackendClient backendClient, + ILogger logger) + { + _sessionManager = sessionManager; + _backendClient = backendClient; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped += OnPlaybackStopped; + return Task.CompletedTask; + } + + /// + public Task StopAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped -= OnPlaybackStopped; + return Task.CompletedTask; + } + + private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e) + { + if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true }) + { + return; + } + + if (!e.PlayedToCompletion) + { + return; + } + + var userId = e.Users?.FirstOrDefault()?.Id.ToString("N"); + var itemId = e.Item?.Id.ToString("N"); + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId)) + { + return; + } + + var occurredAt = DateTimeOffset.UtcNow; + var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId) + ? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}" + : $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}"; + + var payload = new MovieNightEventPayload( + EventId: eventId, + EventType: "playback.stopped", + OccurredAt: occurredAt, + JellyfinUserId: userId, + ItemId: itemId, + PayloadVersion: 1, + Payload: new Dictionary + { + ["itemName"] = e.Item?.Name, + ["playSessionId"] = e.PlaySessionId, + ["positionTicks"] = e.PlaybackPositionTicks, + ["playedToCompletion"] = e.PlayedToCompletion + }); + + _ = Task.Run( + async () => + { + try + { + await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "MovieNight playback event push failed"); + } + }); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs new file mode 100644 index 0000000..93da778 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -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; + +/// +/// Service for synchronizing the Jellyfin library with MovieNight. +/// +public class MovieNightSyncService +{ + private readonly MovieNightBackendClient _backendClient; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightSyncService( + MovieNightBackendClient backendClient, + ILibraryManager libraryManager, + IUserManager userManager, + IUserDataManager userDataManager, + ILogger logger) + { + _backendClient = backendClient; + _libraryManager = libraryManager; + _userManager = userManager; + _userDataManager = userDataManager; + _logger = logger; + } + + /// + /// Performs a full library sync. + /// + public async Task PerformSyncAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting MovieNight library sync"); + + var config = Plugin.Instance?.Configuration; + var enabledLibraryIds = config?.EnabledLibraryIds ?? new List(); + + var query = new InternalItemsQuery + { + IncludeItemTypes = new[] { BaseItemKind.Movie }, + Recursive = true + }; + + if (enabledLibraryIds.Count > 0) + { + query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray(); + } + + var items = _libraryManager.GetItemList(query); + var users = _userManager.Users; + var syncUsers = users.Select(u => new + { + jellyfinUserId = u.Id.ToString("N"), + name = u.Username + }).ToList(); + var syncItems = new List(); + + 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 + { + ["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"); + } +} diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md new file mode 100644 index 0000000..2c3b06a --- /dev/null +++ b/plugins/jellyfin/README.md @@ -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=` +- `JELLYFIN_WEB_URL=` + +`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`. + +Optional backend-pull sync values: + +- `JELLYFIN_BASE_URL=` +- `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. diff --git a/plugins/jellyfin/build.yaml b/plugins/jellyfin/build.yaml new file mode 100644 index 0000000..3c846f3 --- /dev/null +++ b/plugins/jellyfin/build.yaml @@ -0,0 +1,14 @@ +--- +name: "MovieNight" +guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb" +version: 2 +targetAbi: "10.11.0.0" +framework: net9.0 +owner: "movienight" +overview: "Bridge Jellyfin events and sync triggers to MovieNight" +description: "Thin Jellyfin plugin for MovieNight backend integration" +category: "General" +artifacts: + - "Jellyfin.Plugin.MovieNight.dll" +changelog: |- + - Initial plugin implementation. diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index a5c8dcb..1801681 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -26,9 +26,7 @@ class SecurityConfiguration( .requestMatchers("/api/v1/docs/**", "/api/v1/swagger-ui/**", "/swagger-ui/**") .permitAll() .requestMatchers( - "/api/integrations/jellyfin/events", - "/api/integrations/jellyfin/sync", - "/api/integrations/jellyfin/sync-state", + "/api/integrations/jellyfin/**", ).permitAll() .requestMatchers("/api/users/me") .authenticated() diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 0d7a294..2d33a64 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -3,7 +3,6 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest import com.project.movienight.application.ports.input.HandleJellyfinEventCommand import com.project.movienight.application.ports.input.JellyfinEventUseCase -import com.project.movienight.config.JellyfinIntegrationProperties import jakarta.validation.Valid import org.slf4j.LoggerFactory 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.ResponseStatus import org.springframework.web.bind.annotation.RestController -import org.springframework.web.server.ResponseStatusException @RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinEventsController( private val jellyfinEventUseCase: JellyfinEventUseCase, - private val properties: JellyfinIntegrationProperties, + private val authenticator: JellyfinPluginAuthenticator, ) { private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) @@ -29,15 +27,7 @@ class JellyfinEventsController( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @Valid @RequestBody request: JellyfinEventRequest, ) { - if (!properties.enabled) { - 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") - } - } + authenticator.authenticate(token) log.debug( "Received Jellyfin event {} for user {} item {}", diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt new file mode 100644 index 0000000..c2bbb1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt @@ -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") + } + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt new file mode 100644 index 0000000..10bda37 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt @@ -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 { + 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 { + 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, +) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index aa107b7..29d5585 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -1,10 +1,18 @@ 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.JellyfinSyncUserCommand +import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary +import jakarta.validation.Valid import org.springframework.web.bind.annotation.GetMapping 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.RestController @@ -12,10 +20,61 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinSyncController( private val jellyfinSyncUseCase: JellyfinSyncUseCase, + private val authenticator: JellyfinPluginAuthenticator, ) { @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") - fun syncState(): List = jellyfinSyncUseCase.getSyncStates() + fun syncState( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + ): List { + 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, + ) + }, + ) + }, + ) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt new file mode 100644 index 0000000..d916f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt @@ -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 = emptyList(), + @field:Valid + val items: List = 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 = emptyList(), + val imdbId: String? = null, + val tmdbId: String? = null, + val jellyfinLibraryId: String? = null, + @field:Valid + val userStates: List = 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, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt index b28e68d..6be11a2 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand( interface JellyfinSyncUseCase { fun syncNow(): JellyfinSyncSummary + fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary + fun getSyncStates(): List } + +data class IngestJellyfinSyncCommand( + val users: List = emptyList(), + val items: List = 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, + val imdbId: String?, + val tmdbId: String?, + val jellyfinLibraryId: String?, + val userStates: List, +) + +data class JellyfinSyncUserStateCommand( + val jellyfinUserId: String, + val isViewed: Boolean, + val playCount: Int, + val lastPlayedAt: OffsetDateTime?, + val userRating: Double?, +) diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index b35c94c..2747229 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -12,6 +12,7 @@ import com.project.movienight.application.ports.output.JellyfinEventStorePort import com.project.movienight.application.ports.output.UserRepositoryPort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.UUID @Service class JellyfinEventService( @@ -26,6 +27,8 @@ class JellyfinEventService( @Transactional override fun handle(command: HandleJellyfinEventCommand) { + val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId) + val jellyfinItemId = normalizeJellyfinId(command.itemId) val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } val inserted = jellyfinEventStore.save( @@ -34,8 +37,8 @@ class JellyfinEventService( serverId = command.serverId, eventType = command.eventType, occurredAt = command.occurredAt, - jellyfinUserId = command.jellyfinUserId, - jellyfinItemId = command.itemId, + jellyfinUserId = jellyfinUserId, + jellyfinItemId = jellyfinItemId, payload = payloadJson, ), ) @@ -45,13 +48,13 @@ class JellyfinEventService( try { if (playbackEventTypes.contains(command.eventType)) { - val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId) + val localUser = userRepository.findByJellyfinUserId(jellyfinUserId) if (localUser == null) { businessMetricsService.recordJellyfinUnmappedUser() return } - val film = filmRepository.findByJellyfinItemId(command.itemId) + val film = filmRepository.findByJellyfinItemId(jellyfinItemId) if (film == null) { businessMetricsService.recordBackendWriteFailure() return @@ -72,4 +75,8 @@ class JellyfinEventService( throw ex } } + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index cc852d4..56fe3fe 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,5 +1,6 @@ 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.output.BusinessMetricsPort 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.UserRepositoryPort 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.FilmLibraryEntry import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.User import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import java.time.Duration import java.time.Instant import java.time.LocalDateTime +import java.util.Locale import java.util.UUID @Service @@ -56,6 +60,21 @@ class JellyfinSyncService( override fun getSyncStates(): List = 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 { val startedAt = Instant.now() val remoteUsers = jellyfinCatalog.fetchUsers() @@ -63,7 +82,7 @@ class JellyfinSyncService( userRepository .findAll() .mapNotNull { user -> - user.jellyfinUserId?.let { it to user } + user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } }.toMap() var syncedUsers = 0 @@ -71,7 +90,7 @@ class JellyfinSyncService( var syncedItems = 0 remoteUsers.forEach { remoteUser -> - val localUser = localUsersByJellyfinId[remoteUser.id] + val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)] if (localUser == null) { skippedUsers += 1 return@forEach @@ -123,34 +142,39 @@ class JellyfinSyncService( } private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { + val normalizedItem = + item.copy( + jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId), + jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId), + ) val film = - filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, + filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy( + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, ) ?: Film( id = idGenerator.generateId(), - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, ) 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() + val syncedCountsByUserId = mutableMapOf() + + 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) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e277aa3..c4248b1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -97,12 +97,13 @@ info: integrations: jellyfin: - enabled: ${JELLYFIN_SYNC_ENABLED:false} + enabled: ${JELLYFIN_INTEGRATION_ENABLED:false} base-url: ${JELLYFIN_BASE_URL:} web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} api-key: ${JELLYFIN_API_KEY:} sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} + plugin-token: ${JELLYFIN_PLUGIN_TOKEN:} services: user: diff --git a/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt new file mode 100644 index 0000000..0a98a25 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt @@ -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 = + 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, + ), + ), + ), + ), + ) +}