refactor: обновить use case слои и интеграцию Jellyfin #55

Closed
skettiks wants to merge 1 commits from fix-develop-problems into develop
86 changed files with 2920 additions and 1004 deletions
+1
View File
@@ -33,6 +33,7 @@ dependencies {
implementation(libs.spring.boot.starter.web)
implementation(libs.spring.boot.starter.actuator)
implementation(libs.spring.boot.starter.aop)
implementation(libs.spring.boot.starter.security)
implementation(libs.spring.boot.starter.cache)
implementation(libs.spring.boot.starter.data.jdbc)
+1
View File
@@ -17,6 +17,7 @@ mockk = "1.13.13"
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" }
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" }
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" }
spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" }
+2
View File
@@ -0,0 +1,2 @@
**/bin/
**/obj/
@@ -0,0 +1,50 @@
using System.Collections.Generic;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.MovieNight.Configuration;
/// <summary>
/// MovieNight plugin settings persisted by Jellyfin.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets a value indicating whether integration calls are enabled.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets the MovieNight backend base URL.
/// </summary>
public string BackendBaseUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the backend plugin token.
/// </summary>
public string ApiToken { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the periodic sync interval in minutes.
/// </summary>
public int SyncIntervalMinutes { get; set; } = 30;
/// <summary>
/// Gets or sets a value indicating whether playback stop events are pushed to MovieNight.
/// </summary>
public bool EnablePlaybackEvents { get; set; } = true;
/// <summary>
/// Gets or sets a value indicating whether periodic backend sync is enabled.
/// </summary>
public bool EnablePeriodicSync { get; set; } = true;
/// <summary>
/// Gets or sets enabled Jellyfin library ids. Empty means all libraries.
/// </summary>
public List<string> EnabledLibraryIds { get; set; } = new();
/// <summary>
/// Gets or sets the path where .strm files will be created.
/// </summary>
public string StrmOutputPath { get; set; } = string.Empty;
}
@@ -0,0 +1,94 @@
const movieNightConfigPage = {
pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb",
loadConfiguration(view) {
Dashboard.showLoadingMsg();
return ApiClient.getPluginConfiguration(this.pluginId)
.then((config) => {
view.querySelector("#BackendBaseUrl").value =
config.BackendBaseUrl || "";
view.querySelector("#ApiToken").value = config.ApiToken || "";
view.querySelector("#SyncIntervalMinutes").value =
config.SyncIntervalMinutes || 30;
view.querySelector("#StrmOutputPath").value =
config.StrmOutputPath || "";
view.querySelector("#Enabled").checked = config.Enabled || false;
view.querySelector("#EnablePeriodicSync").checked =
config.EnablePeriodicSync !== false;
view.querySelector("#EnablePlaybackEvents").checked =
config.EnablePlaybackEvents !== false;
const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", {
name: "MovieNight.ui.js",
});
view.querySelector("#UIScriptUrl").innerText = uiScriptUrl;
})
.finally(() => {
Dashboard.hideLoadingMsg();
});
},
saveConfiguration(view) {
const form = view.querySelector("#MovieNightConfigForm");
Dashboard.showLoadingMsg();
return ApiClient.getPluginConfiguration(this.pluginId)
.then((config) => {
config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value;
config.ApiToken = form.querySelector("#ApiToken").value;
config.SyncIntervalMinutes = parseInt(
form.querySelector("#SyncIntervalMinutes").value || "30",
10,
);
config.StrmOutputPath = form.querySelector("#StrmOutputPath").value;
config.Enabled = form.querySelector("#Enabled").checked;
config.EnablePeriodicSync =
form.querySelector("#EnablePeriodicSync").checked;
config.EnablePlaybackEvents =
form.querySelector("#EnablePlaybackEvents").checked;
return ApiClient.updatePluginConfiguration(this.pluginId, config);
})
.then((result) => {
Dashboard.processPluginConfigurationUpdateResult(result);
})
.finally(() => {
Dashboard.hideLoadingMsg();
});
},
testConnection() {
Dashboard.showLoadingMsg();
return ApiClient.ajax({
type: "POST",
url: ApiClient.getUrl("MovieNight/TestConnection"),
})
.then((result) => {
Dashboard.alert((result && result.message) || "OK");
})
.catch(() => {
Dashboard.alert("MovieNight connection test failed");
})
.finally(() => {
Dashboard.hideLoadingMsg();
});
},
};
export default function (view) {
movieNightConfigPage.loadConfiguration(view);
view
.querySelector("#MovieNightConfigForm")
.addEventListener("submit", (event) => {
event.preventDefault();
movieNightConfigPage.saveConfiguration(view);
});
view.querySelector("#TestConnection").addEventListener("click", (event) => {
event.preventDefault();
movieNightConfigPage.testConnection();
});
}
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<title>MovieNight</title>
</head>
<body>
<div
id="MovieNightConfigPage"
data-role="page"
class="page type-interior pluginConfigurationPage"
data-controller="__plugin/MovieNight.js">
<div data-role="content">
<div class="content-primary">
<form id="MovieNightConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="BackendBaseUrl">Backend URL</label>
<input is="emby-input" id="BackendBaseUrl" name="BackendBaseUrl" type="url" placeholder="http://localhost:8080" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ApiToken">Plugin token</label>
<input is="emby-input" id="ApiToken" name="ApiToken" type="password" autocomplete="new-password" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="SyncIntervalMinutes">Sync interval minutes</label>
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="StrmOutputPath">STRM output path</label>
<input is="emby-input" id="StrmOutputPath" name="StrmOutputPath" type="text" placeholder="/data/movies/movienight" />
<div class="fieldDescription">Directory where .strm files will be created for new films.</div>
</div>
<label class="checkboxContainer">
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
<span>Enable MovieNight integration</span>
</label>
<label class="checkboxContainer">
<input id="EnablePeriodicSync" name="EnablePeriodicSync" type="checkbox" is="emby-checkbox" />
<span>Enable periodic backend sync</span>
</label>
<label class="checkboxContainer">
<input id="EnablePlaybackEvents" name="EnablePlaybackEvents" type="checkbox" is="emby-checkbox" />
<span>Send playback stop events</span>
</label>
<div>
<button is="emby-button" type="submit" class="raised button-submit block">
<span>Save</span>
</button>
</div>
<div>
<button is="emby-button" type="button" id="TestConnection" class="raised block">
<span>Test connection</span>
</button>
</div>
<div style="margin-top: 2em; padding: 1em; background: #333; border-radius: 4px;">
<h3>UI Integration</h3>
<p>To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin <strong>Custom JavaScript</strong> setting (Dashboard > General):</p>
<code id="UIScriptUrl" style="display: block; padding: 0.5em; background: #000; word-break: break-all;"></code>
</div>
</form>
</div>
</div>
</div>
</body>
</html>
@@ -0,0 +1,339 @@
(function () {
const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
function getAlert() {
if (typeof Dashboard !== 'undefined' && Dashboard.alert) {
return (options) => Dashboard.alert(options);
}
return (options) => {
const msg = typeof options === 'string' ? options : (options.text || options.title);
alert(msg);
};
}
const showMsg = getAlert();
function createTextButton(text, className, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.is = 'emby-button';
btn.className = `emby-button raised ${className}`;
btn.style.margin = '0.5em';
btn.style.padding = '0.4em 1em';
btn.innerHTML = `<span>${text}</span>`;
btn.onclick = onClick;
return btn;
}
function createIconButton(icon, title, className, onClick) {
const btn = document.createElement('button');
btn.type = 'button';
btn.is = 'emby-button';
btn.className = `button-flat detailButton emby-button ${className}`;
btn.title = title;
btn.innerHTML = `
<div class="detailButton-content">
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
</div>
`;
btn.onclick = onClick;
return btn;
}
function injectUI() {
// 1. Item Detail Page
const detailButtons = document.querySelector('.mainDetailButtons');
if (detailButtons) {
const itemId = getItemIdFromUrl();
if (itemId) {
// MovieNight Rating
if (!document.querySelector('.btnMovieNightRate')) {
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
});
insertInDetailRow(detailButtons, rateBtn);
}
// Mark Viewed in MovieNight
if (!document.querySelector('.btnMovieNightMarkViewed')) {
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
});
insertInDetailRow(detailButtons, viewedBtn);
}
}
}
// 2. Library Pages - Add text buttons to toolbar
const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
e.preventDefault(); showRecommendation();
}));
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
e.preventDefault(); showAddMovieDialog();
}));
}
// 3. Home Page - Prepend a MovieNight section
const homeSections = document.querySelector('.sections.homeSectionsContainer');
if (homeSections && !document.querySelector('.movieNightHomeButtons')) {
const section = document.createElement('div');
section.className = 'verticalSection movieNightHomeButtons';
section.style.padding = '0 var(--sidePadding)';
section.innerHTML = `
<div class="sectionTitleContainer" style="display:flex; align-items:center; justify-content:space-between;">
<h2 class="sectionTitle">MovieNight</h2>
<span class="movieNightSyncStatus" style="font-size:0.8em; opacity:0.7;"></span>
</div>
<div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap; margin-top:0.5em;"></div>
`;
const btnContainer = section.querySelector('.movieNightBtnContainer');
btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog));
btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync));
homeSections.insertBefore(section, homeSections.firstChild);
updateSyncStatus();
}
}
function insertInDetailRow(container, btn) {
const moreBtn = container.querySelector('.btnMoreCommands');
if (moreBtn) container.insertBefore(btn, moreBtn);
else container.appendChild(btn);
}
function getItemIdFromUrl() {
const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search;
const params = new URLSearchParams(queryString);
return params.get('id') || params.get('itemId');
}
function createOverlay() {
const overlay = document.createElement('div');
overlay.className = 'dialogBackdrop dialogBackdropOpened';
overlay.style.zIndex = '99998';
overlay.style.backgroundColor = 'rgba(0,0,0,0.7)';
overlay.style.position = 'fixed';
overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0';
overlay.style.backdropFilter = 'blur(8px)';
overlay.style.opacity = '1';
return overlay;
}
function createDialogBase(title) {
const dialog = document.createElement('div');
dialog.className = 'dialog';
dialog.style.position = 'fixed';
dialog.style.top = '50%'; dialog.style.left = '50%';
dialog.style.transform = 'translate(-50%, -50%)';
dialog.style.zIndex = '99999';
dialog.style.padding = '2.5em';
dialog.style.minWidth = '350px';
dialog.style.backgroundColor = '#1a1a1a';
dialog.style.borderRadius = '1.5em';
dialog.style.color = 'white';
dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)';
dialog.style.border = '1px solid #444';
dialog.style.opacity = '1';
dialog.innerHTML = `
<h2 style="margin-top:0; text-align:center; font-weight:400; color:white; opacity:1;">${title}</h2>
<div class="dialog-content" style="margin:1.5em 0; opacity:1;"></div>
<div class="dialog-footer" style="display:flex; gap:1em; opacity:1;">
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white !important; opacity:1;">Cancel</button>
</div>
`;
return dialog;
}
async function showRatingDialog(itemId) {
const overlay = createOverlay();
const dialog = createDialogBase('Rate on MovieNight');
const content = dialog.querySelector('.dialog-content');
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.8em;"></div>`;
const grid = content.querySelector('.rating-grid');
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
for (let i = 1; i <= 10; i++) {
const btn = document.createElement('button');
btn.type = 'button'; btn.is = 'emby-button';
btn.className = 'emby-button raised';
btn.innerText = i;
btn.style.padding = '0.8em 0';
btn.style.textAlign = 'center';
btn.style.display = 'flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.style.fontSize = '1.2em';
btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
grid.appendChild(btn);
}
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
async function showAddMovieDialog() {
const overlay = createOverlay();
const dialog = createDialogBase('Add Movie (STRM)');
const content = dialog.querySelector('.dialog-content');
const footer = dialog.querySelector('.dialog-footer');
content.innerHTML = `
<div style="margin-bottom:1em;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Movie Title (Required)</label>
<input type="text" class="emby-input txtTitle" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="e.g. Inception">
</div>
<div style="display:flex; gap:1em; margin-bottom:1em;">
<div style="flex:1;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Year</label>
<input type="number" class="emby-input txtYear" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="2010">
</div>
<div style="flex:2;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">IMDb ID</label>
<input type="text" class="emby-input txtImdb" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="tt1375666">
</div>
</div>
<div>
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Stream URL (Optional)</label>
<input type="text" class="emby-input txtUrl" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="http://...">
</div>
`;
const btnAdd = document.createElement('button');
btnAdd.className = 'emby-button raised button-submit';
btnAdd.style.flex = '2';
btnAdd.style.backgroundColor = '#0064d2';
btnAdd.innerHTML = '<span>Add Film</span>';
footer.insertBefore(btnAdd, footer.firstChild);
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
btnAdd.onclick = async () => {
const title = dialog.querySelector('.txtTitle').value;
const year = dialog.querySelector('.txtYear').value;
const imdbId = dialog.querySelector('.txtImdb').value;
const url = dialog.querySelector('.txtUrl').value;
if (!title) return;
cleanup();
await addMovie(title, url, year, imdbId);
};
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
dialog.querySelector('.txtTitle').focus();
}
async function showRecommendation() {
const userId = ApiClient.getCurrentUserId();
try {
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
if (recommendations && recommendations.length > 0) {
const rec = recommendations[0];
const film = rec.film || rec;
showMsg({
title: 'MovieNight Recommendation',
text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}`
});
} else {
showMsg('No recommendations found at the moment.');
}
} catch (err) {
console.error('Failed to get recommendations', err);
showMsg('Failed to get recommendations. Check your API token and MovieNight status.');
}
}
async function addMovie(title, url, year, imdbId) {
try {
const data = { title, url };
if (year) data.year = parseInt(year);
if (imdbId) data.imdbId = imdbId;
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Films`),
data: JSON.stringify(data),
contentType: 'application/json'
});
showMsg(`STRM file created for "${title}". Refresh your library to see it.`);
} catch (err) {
console.error('Failed to create movie', err);
showMsg('Failed to create movie. Ensure STRM output path is configured.');
}
}
async function triggerSync() {
try {
await ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl(`MovieNight/Sync`) });
showMsg('Library sync triggered!');
setTimeout(updateSyncStatus, 2000);
} catch (err) {
showMsg('Failed to trigger sync.');
}
}
async function updateSyncStatus() {
const statusEl = document.querySelector('.movieNightSyncStatus');
if (!statusEl) return;
try {
const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`));
if (state && state.lastSyncAt) {
statusEl.innerText = `Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`;
}
} catch (err) { /* ignore */ }
}
async function submitRating(itemId, score) {
const userId = ApiClient.getCurrentUserId();
try {
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
contentType: 'application/json'
});
showMsg('Rating submitted to MovieNight!');
} catch (err) {
showMsg('Failed to submit rating.');
}
}
async function submitViewed(itemId) {
const userId = ApiClient.getCurrentUserId();
try {
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Library/Films/${itemId}/Viewed`),
data: JSON.stringify({ watchedAt: new Date().toISOString() }),
contentType: 'application/json'
});
showMsg('Marked as viewed in MovieNight!');
} catch (err) {
showMsg('Failed to mark as viewed.');
}
}
let timeout;
const throttledInject = () => {
if (timeout) return;
timeout = setTimeout(() => {
injectUI();
timeout = null;
}, 100);
};
const observer = new MutationObserver(throttledInject);
observer.observe(document.body, { childList: true, subtree: true });
injectUI();
})();
@@ -0,0 +1,229 @@
using System;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.MovieNight.Services;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.MovieNight.Controllers;
/// <summary>
/// Admin endpoints for the MovieNight plugin.
/// </summary>
[ApiController]
[Route("MovieNight")]
public class MovieNightController : ControllerBase
{
private readonly MovieNightBackendClient _backendClient;
private readonly MovieNightSyncService _syncService;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
/// </summary>
public MovieNightController(
MovieNightBackendClient backendClient,
MovieNightSyncService syncService)
{
_backendClient = backendClient;
_syncService = syncService;
}
/// <summary>
/// Ping endpoint for connectivity checks.
/// </summary>
[HttpGet("Ping")]
public ActionResult Ping() => Ok("Pong");
/// <summary>
/// Returns plugin status.
/// </summary>
/// <returns>Status response.</returns>
[HttpGet("Status")]
[Authorize]
public ActionResult<MovieNightPluginStatus> GetStatus()
{
var configuration = Plugin.Instance?.Configuration;
return new MovieNightPluginStatus(
Enabled: configuration?.Enabled ?? false,
BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty,
PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false,
PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false,
SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30);
}
/// <summary>
/// Tests backend connectivity.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connection result.</returns>
[HttpPost("TestConnection")]
[Authorize]
public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken)
{
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Triggers backend sync.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns>
[HttpPost("Sync")]
[Authorize]
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
{
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
return Ok("Sync triggered");
}
/// <summary>
/// Gets backend sync state.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response.</returns>
[HttpGet("SyncState")]
[Authorize]
public async Task<ActionResult<string>> SyncState(CancellationToken cancellationToken)
{
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Gets recommendations for the current user.
/// </summary>
[HttpGet("Users/{userId}/Recommendations")]
[Authorize]
public async Task<ActionResult<string>> GetRecommendations(
[FromRoute] string userId,
[FromQuery] string? contentType,
[FromQuery] string? mood,
[FromQuery] int limit = 10,
CancellationToken cancellationToken = default)
{
return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
[Authorize]
public async Task<ActionResult> PostRating(
[FromRoute] string userId,
[FromRoute] string filmId,
[FromBody] RatingRequest request,
CancellationToken cancellationToken)
{
await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
[Authorize]
public async Task<ActionResult> MarkViewed(
[FromRoute] string userId,
[FromRoute] string filmId,
[FromBody] ViewedRequest request,
CancellationToken cancellationToken)
{
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
/// </summary>
[HttpPost("Films")]
[Authorize]
public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request)
{
var config = Plugin.Instance?.Configuration;
if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath))
{
return BadRequest("STRM output path is not configured.");
}
if (string.IsNullOrWhiteSpace(request.Title))
{
return BadRequest("Movie title is required.");
}
try
{
// Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]"
var folderName = request.Title.Trim();
if (request.Year.HasValue)
{
folderName += $" ({request.Year})";
}
if (!string.IsNullOrWhiteSpace(request.ImdbId))
{
var ttId = request.ImdbId.Trim().ToLowerInvariant();
if (!ttId.StartsWith("tt")) ttId = "tt" + ttId;
folderName += $" [imdbid-{ttId}]";
}
// Sanitize for file system
var invalidChars = Path.GetInvalidFileNameChars();
var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray());
var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName);
if (!Directory.Exists(movieDirectory))
{
Directory.CreateDirectory(movieDirectory);
}
var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm");
var strmContent = string.IsNullOrWhiteSpace(request.Url)
? "http://placeholder.url/upload_me_later"
: request.Url.Trim();
await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false);
return Ok(new { FilePath = filePath, FolderName = safeFolderName });
}
catch (Exception ex)
{
return StatusCode(500, $"Failed to create film: {ex.Message}");
}
}
}
/// <summary>
/// Create film request.
/// </summary>
public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
/// <summary>
/// Rating request.
/// </summary>
public sealed record RatingRequest(int Score, string? Note);
/// <summary>
/// Viewed request.
/// </summary>
public sealed record ViewedRequest(DateTimeOffset? WatchedAt);
/// <summary>
/// MovieNight plugin status response.
/// </summary>
/// <param name="Enabled">Whether integration is enabled.</param>
/// <param name="BackendBaseUrl">Backend base URL.</param>
/// <param name="PeriodicSyncEnabled">Whether periodic sync is enabled.</param>
/// <param name="PlaybackEventsEnabled">Whether playback events are enabled.</param>
/// <param name="SyncIntervalMinutes">Sync interval in minutes.</param>
public sealed record MovieNightPluginStatus(
bool Enabled,
string BackendBaseUrl,
bool PeriodicSyncEnabled,
bool PlaybackEventsEnabled,
int SyncIntervalMinutes);
@@ -0,0 +1,36 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.MovieNight</RootNamespace>
<AssemblyName>Jellyfin.Plugin.MovieNight</AssemblyName>
<Version>1.0.0.1</Version>
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
<Nullable>enable</Nullable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Jellyfin.Common" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<None Remove="Configuration\config.js" />
<None Remove="Configuration\ui.js" />
<EmbeddedResource Include="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\config.js" />
<EmbeddedResource Include="Configuration\ui.js" />
</ItemGroup>
</Project>
@@ -0,0 +1,73 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.MovieNight.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.MovieNight;
/// <summary>
/// MovieNight Jellyfin plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Application paths.</param>
/// <param name="xmlSerializer">XML serializer.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "MovieNight";
/// <inheritdoc />
public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb");
/// <inheritdoc />
public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend.";
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.configPage.html",
GetType().Namespace)
},
new PluginPageInfo
{
Name = Name + ".js",
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.config.js",
GetType().Namespace)
},
new PluginPageInfo
{
Name = Name + ".ui.js",
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.ui.js",
GetType().Namespace)
}
];
}
}
@@ -0,0 +1,21 @@
using Jellyfin.Plugin.MovieNight.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.MovieNight;
/// <summary>
/// Registers MovieNight services with Jellyfin.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
serviceCollection.AddSingleton<MovieNightBackendClient>();
serviceCollection.AddSingleton<MovieNightSyncService>();
serviceCollection.AddHostedService<MovieNightPeriodicSyncService>();
serviceCollection.AddHostedService<MovieNightPlaybackEventService>();
}
}
@@ -0,0 +1,281 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Thin HTTP client for the MovieNight backend.
/// </summary>
public class MovieNightBackendClient
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
private readonly ILogger<MovieNightBackendClient> _logger;
private readonly HttpClient _httpClient;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightBackendClient"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public MovieNightBackendClient(ILogger<MovieNightBackendClient> logger)
{
_logger = logger;
_httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(20)
};
}
/// <summary>
/// Calls backend health.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Connection result.</returns>
public async Task<MovieNightConnectionResult> TestConnectionAsync(CancellationToken cancellationToken)
{
var payload = new MovieNightEventPayload(
EventId: $"plugin-test:{Guid.NewGuid():N}",
EventType: "playback.stopped",
OccurredAt: DateTimeOffset.UtcNow,
JellyfinUserId: "movienight-plugin-test-user",
ItemId: "movienight-plugin-test-item",
PayloadVersion: 1,
Payload: new Dictionary<string, object?>
{
["source"] = "config-test"
});
var request = CreateEventRequest(payload);
if (request is null)
{
return MovieNightConnectionResult.Failed("Plugin is not configured.");
}
try
{
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
return response.IsSuccessStatusCode
? MovieNightConnectionResult.Ok()
: MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}.");
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
_logger.LogWarning(ex, "MovieNight connection test failed");
return MovieNightConnectionResult.Failed(ex.Message);
}
}
/// <summary>
/// Pushes library sync data to the backend.
/// </summary>
/// <param name="payload">Sync payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response body.</returns>
public async Task<string> SyncAsync(object payload, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync");
if (request is null)
{
return "Plugin is not configured.";
}
request.Content = JsonContent.Create(payload, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Gets recommendations for a user.
/// </summary>
public async Task<string> GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken)
{
var query = $"?limit={limit}";
if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}";
if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}";
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/recommendations{query}");
if (request is null) return "Plugin is not configured.";
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Posts a rating for a film.
/// </summary>
public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/ratings/films/{filmId}");
if (request is null) return;
request.Content = JsonContent.Create(new { score, note }, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Gets ratings for a user.
/// </summary>
public async Task<string> GetRatingsAsync(string userId, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/ratings");
if (request is null) return "[]";
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Marks a film as viewed.
/// </summary>
public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed");
if (request is null) return;
request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Reads backend sync state.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Backend response body.</returns>
public async Task<string> GetSyncStateAsync(CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state");
if (request is null)
{
return "Plugin is not configured.";
}
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body;
}
/// <summary>
/// Pushes an event payload to the backend event endpoint.
/// </summary>
/// <param name="payload">Event payload.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task.</returns>
public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken)
{
for (var attempt = 1; attempt <= 3; attempt++)
{
var request = CreateEventRequest(payload);
if (request is null)
{
return;
}
try
{
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.IsSuccessStatusCode)
{
return;
}
if ((int)response.StatusCode == 401)
{
_logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized");
return;
}
_logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode);
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
{
_logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt);
}
if (attempt < 3)
{
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false);
}
}
}
private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload)
{
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events");
if (request is null)
{
return null;
}
request.Content = JsonContent.Create(payload, options: JsonOptions);
return request;
}
private static string? GetBaseUrl()
{
var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim();
return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/');
}
private static bool IsEnabled()
{
var configuration = Plugin.Instance?.Configuration;
return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl);
}
private static HttpRequestMessage? CreateRequest(HttpMethod method, string path)
{
if (!IsEnabled())
{
return null;
}
var baseUrl = GetBaseUrl();
if (baseUrl is null)
{
return null;
}
var request = new HttpRequestMessage(method, new Uri(baseUrl + path));
var token = Plugin.Instance?.Configuration.ApiToken;
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.Add("X-MovieNight-Plugin-Token", token);
}
return request;
}
}
/// <summary>
/// Backend connection result.
/// </summary>
/// <param name="Success">Whether the call succeeded.</param>
/// <param name="Message">Result message.</param>
public sealed record MovieNightConnectionResult(bool Success, string Message)
{
/// <summary>
/// Creates a successful result.
/// </summary>
/// <returns>Connection result.</returns>
public static MovieNightConnectionResult Ok() => new(true, "OK");
/// <summary>
/// Creates a failed result.
/// </summary>
/// <param name="message">Failure message.</param>
/// <returns>Connection result.</returns>
public static MovieNightConnectionResult Failed(string message) => new(false, message);
}
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Event payload sent to MovieNight.
/// </summary>
/// <param name="EventId">Idempotency key.</param>
/// <param name="EventType">Event type.</param>
/// <param name="OccurredAt">Event timestamp.</param>
/// <param name="JellyfinUserId">Jellyfin user id.</param>
/// <param name="ItemId">Jellyfin item id.</param>
/// <param name="PayloadVersion">Payload version.</param>
/// <param name="Payload">Extra event data.</param>
public sealed record MovieNightEventPayload(
[property: JsonPropertyName("event_id")]
string EventId,
[property: JsonPropertyName("event_type")]
string EventType,
[property: JsonPropertyName("occurred_at")]
DateTimeOffset OccurredAt,
[property: JsonPropertyName("jellyfin_user_id")]
string JellyfinUserId,
[property: JsonPropertyName("item_id")]
string ItemId,
[property: JsonPropertyName("payload_version")]
int PayloadVersion,
[property: JsonPropertyName("payload")]
IReadOnlyDictionary<string, object?> Payload);
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Periodically asks MovieNight to run its current Jellyfin sync.
/// </summary>
public sealed class MovieNightPeriodicSyncService : BackgroundService
{
private readonly MovieNightSyncService _syncService;
private readonly ILogger<MovieNightPeriodicSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPeriodicSyncService"/> class.
/// </summary>
public MovieNightPeriodicSyncService(
MovieNightSyncService syncService,
ILogger<MovieNightPeriodicSyncService> logger)
{
_syncService = syncService;
_logger = logger;
}
/// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
var delay = GetDelay();
try
{
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
if (!ShouldRun())
{
continue;
}
await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "MovieNight periodic sync failed");
}
}
}
private static bool ShouldRun()
{
var configuration = Plugin.Instance?.Configuration;
return configuration is { Enabled: true, EnablePeriodicSync: true };
}
private static TimeSpan GetDelay()
{
var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30;
return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440));
}
}
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Subscribes to Jellyfin playback events and forwards thin payloads.
/// </summary>
public sealed class MovieNightPlaybackEventService : IHostedService
{
private readonly ISessionManager _sessionManager;
private readonly MovieNightBackendClient _backendClient;
private readonly ILogger<MovieNightPlaybackEventService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPlaybackEventService"/> class.
/// </summary>
/// <param name="sessionManager">Jellyfin session manager.</param>
/// <param name="backendClient">Backend client.</param>
/// <param name="logger">Logger.</param>
public MovieNightPlaybackEventService(
ISessionManager sessionManager,
MovieNightBackendClient backendClient,
ILogger<MovieNightPlaybackEventService> logger)
{
_sessionManager = sessionManager;
_backendClient = backendClient;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped += OnPlaybackStopped;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
return Task.CompletedTask;
}
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
{
if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true })
{
return;
}
if (!e.PlayedToCompletion)
{
return;
}
var userId = e.Users?.FirstOrDefault()?.Id.ToString("N");
var itemId = e.Item?.Id.ToString("N");
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId))
{
return;
}
var occurredAt = DateTimeOffset.UtcNow;
var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId)
? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}"
: $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}";
var payload = new MovieNightEventPayload(
EventId: eventId,
EventType: "playback.stopped",
OccurredAt: occurredAt,
JellyfinUserId: userId,
ItemId: itemId,
PayloadVersion: 1,
Payload: new Dictionary<string, object?>
{
["itemName"] = e.Item?.Name,
["playSessionId"] = e.PlaySessionId,
["positionTicks"] = e.PlaybackPositionTicks,
["playedToCompletion"] = e.PlayedToCompletion
});
_ = Task.Run(
async () =>
{
try
{
await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "MovieNight playback event push failed");
}
});
}
}
@@ -0,0 +1,105 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Jellyfin.Data.Enums;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Service for synchronizing the Jellyfin library with MovieNight.
/// </summary>
public class MovieNightSyncService
{
private readonly MovieNightBackendClient _backendClient;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly IUserDataManager _userDataManager;
private readonly ILogger<MovieNightSyncService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightSyncService"/> class.
/// </summary>
public MovieNightSyncService(
MovieNightBackendClient backendClient,
ILibraryManager libraryManager,
IUserManager userManager,
IUserDataManager userDataManager,
ILogger<MovieNightSyncService> logger)
{
_backendClient = backendClient;
_libraryManager = libraryManager;
_userManager = userManager;
_userDataManager = userDataManager;
_logger = logger;
}
/// <summary>
/// Performs a full library sync.
/// </summary>
public async Task PerformSyncAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting MovieNight library sync");
var config = Plugin.Instance?.Configuration;
var enabledLibraryIds = config?.EnabledLibraryIds ?? new List<string>();
var query = new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie },
Recursive = true
};
if (enabledLibraryIds.Count > 0)
{
query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray();
}
var items = _libraryManager.GetItemList(query);
var users = _userManager.Users;
var syncItems = new List<object>();
foreach (var item in items)
{
if (item is not Movie movie) continue;
var jellyfinItemId = movie.Id.ToString("N");
var itemData = new Dictionary<string, object?>
{
["jellyfinItemId"] = jellyfinItemId,
["title"] = movie.Name,
["originalTitle"] = movie.OriginalTitle,
["description"] = movie.Overview,
["year"] = movie.ProductionYear,
["duration"] = movie.RunTimeTicks,
["genres"] = movie.Genres,
["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary",
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb),
["userStates"] = users.Select(u => {
var userData = _userDataManager.GetUserData(u, movie);
return new {
jellyfinUserId = u.Id.ToString("N"),
isViewed = userData?.Played ?? false,
playCount = userData?.PlayCount ?? 0,
lastPlayedAt = userData?.LastPlayedDate,
userRating = userData?.Rating
};
}).ToList()
};
syncItems.Add(itemData);
}
await _backendClient.SyncAsync(new { items = syncItems }, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("MovieNight library sync completed");
}
}
+48
View File
@@ -0,0 +1,48 @@
# MovieNight Jellyfin Plugin
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
## Build
```bash
cd plugins/jellyfin/Jellyfin.Plugin.MovieNight
dotnet publish -c Release
```
Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`.
## Backend Contract Used
Current implemented calls:
- `POST /api/integrations/jellyfin/sync`
- `GET /api/integrations/jellyfin/sync-state`
- `POST /api/integrations/jellyfin/events`
Event requests use JSON with:
- `event_id`
- `event_type`
- `occurred_at`
- `jellyfin_user_id`
- `item_id`
- `payload_version`
- `payload`
The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`.
The config page test action posts a small synthetic event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure.
Sync requests use JSON with:
- `items`
- `items[].jellyfinItemId`
- `items[].title`
- `items[].description`
- `items[].year`
- `items[].genres`
- `items[].imdbId`
- `items[].userStates`
- `items[].userStates[].jellyfinUserId`
- `items[].userStates[].isViewed`
- `items[].userStates[].lastPlayedAt`
+14
View File
@@ -0,0 +1,14 @@
---
name: "MovieNight"
guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb"
version: 2
targetAbi: "10.11.0.0"
framework: net9.0
owner: "movienight"
overview: "Bridge Jellyfin events and sync triggers to MovieNight"
description: "Thin Jellyfin plugin for MovieNight backend integration"
category: "General"
artifacts:
- "Jellyfin.Plugin.MovieNight.dll"
changelog: |-
- Initial plugin implementation.
@@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.application.ports.output.JellyfinCatalogPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
import com.project.movienight.application.ports.output.JellyfinRemoteUser
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.stereotype.Service
@@ -11,39 +14,18 @@ import java.net.http.HttpRequest
import java.net.http.HttpResponse
import java.time.Duration
data class JellyfinRemoteUser(
val id: String,
val name: String,
)
data class JellyfinLibraryItemSnapshot(
val jellyfinItemId: String,
val title: String,
val description: String,
val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val jellyfinLibraryId: String?,
val isPlayed: Boolean,
)
@Service
class JellyfinApiClient(
private val properties: JellyfinIntegrationProperties,
private val objectMapper: ObjectMapper,
) {
) : JellyfinCatalogPort {
private val httpClient: HttpClient =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build()
fun fetchUsers(): List<JellyfinRemoteUser> =
override fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users")
.asItems()
.mapNotNull { node ->
@@ -51,7 +33,7 @@ class JellyfinApiClient(
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
}
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
override fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
@Suppress("MaxLineLength")
request(
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.metrics
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
import io.micrometer.core.instrument.Counter
@@ -11,8 +12,12 @@ import java.util.concurrent.atomic.AtomicInteger
@Service
class BusinessMetricsService(
private val meterRegistry: MeterRegistry,
) {
) : BusinessMetricsPort {
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total")
private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total")
private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total")
private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_total")
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total")
private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total")
@@ -33,11 +38,27 @@ class BusinessMetricsService(
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
fun recordRecommendationRequest() {
override fun recordFilmCreated() {
filmsCreated.increment()
}
override fun recordFilmEdited() {
filmsEdited.increment()
}
override fun recordFilmDeleted() {
filmsDeleted.increment()
}
override fun recordFilmBlocked() {
filmsBlocked.increment()
}
override fun recordRecommendationRequest() {
recommendationRequests.increment()
}
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
Counter
.builder("recommendation_weights_updated_total")
.tag("eventType", eventType.name)
@@ -45,15 +66,15 @@ class BusinessMetricsService(
.increment()
}
fun recordRatingSubmitted() {
override fun recordRatingSubmitted() {
ratingsSubmitted.increment()
}
fun recordLibraryEvent() {
override fun recordLibraryEvent() {
libraryEvents.increment()
}
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
jellyfinSyncRuns.increment()
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
@@ -61,15 +82,15 @@ class BusinessMetricsService(
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
}
fun recordJellyfinSyncFailure() {
override fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment()
}
fun recordJellyfinUnmappedUser() {
override fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
}
fun recordBackendWriteFailure() {
override fun recordBackendWriteFailure() {
backendWriteFailures.increment()
}
}
@@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User =
id = id,
name = name,
email = email,
library = null,
preferences = null,
jellyfinUserId = jellyfinUserId,
)
@@ -1,18 +1,18 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.domain.model.FilmLibraryEntry
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class FilmLibraryRepository(
class FilmLibraryEntryRepository(
private val jdbc: JdbcTemplate,
) : FilmLibraryRepositoryPort {
private val filmLibraryRowMapper = { rs: ResultSet, _: Int ->
FilmLibrary(
) : FilmLibraryEntryRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
FilmLibraryEntry(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
@@ -22,7 +22,7 @@ class FilmLibraryRepository(
)
}
override fun save(filmLibrary: FilmLibrary): FilmLibrary {
override fun save(entry: FilmLibraryEntry): FilmLibraryEntry {
val updatedRows =
jdbc.update(
"""
@@ -30,12 +30,12 @@ class FilmLibraryRepository(
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ?
WHERE id = ?
""".trimIndent(),
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
filmLibrary.watchedAt,
filmLibrary.id,
entry.userId,
entry.filmId,
entry.comment,
entry.isViewed,
entry.watchedAt,
entry.id,
)
if (updatedRows == 0) {
jdbc.update(
@@ -43,48 +43,51 @@ class FilmLibraryRepository(
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at)
VALUES (?, ?, ?, ?, ?, ?)
""".trimIndent(),
filmLibrary.id,
filmLibrary.userId,
filmLibrary.filmId,
filmLibrary.comment,
filmLibrary.isViewed,
filmLibrary.watchedAt,
entry.id,
entry.userId,
entry.filmId,
entry.comment,
entry.isViewed,
entry.watchedAt,
)
}
return filmLibrary
return entry
}
override fun findById(id: UUID): FilmLibrary? {
val entries =
jdbc.query(
override fun findById(id: UUID): FilmLibraryEntry? =
jdbc
.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
filmLibraryRowMapper,
rowMapper,
id,
)
return entries.firstOrNull()
}
).firstOrNull()
override fun findByUserId(userId: UUID): List<FilmLibraryEntry> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?",
rowMapper,
userId,
)
override fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary? {
val entries =
jdbc.query(
): FilmLibraryEntry? =
jdbc
.query(
"""
SELECT id, user_id, film_id, comment, is_viewed, watched_at
FROM favorites WHERE user_id = ? AND film_id = ?
""".trimIndent(),
filmLibraryRowMapper,
rowMapper,
userId,
filmId,
)
return entries.firstOrNull()
}
).firstOrNull()
override fun findAll(): List<FilmLibrary> =
override fun findAll(): List<FilmLibraryEntry> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
filmLibraryRowMapper,
rowMapper,
)
override fun deleteById(id: UUID) {
@@ -159,32 +159,6 @@ class FilmRepository(
return films.firstOrNull()
}
override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? {
val films =
jdbc.query(
"""
SELECT id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
FROM films
WHERE jellyfin_library_id = ?
""".trimIndent(),
filmRowMapper,
jellyfinLibraryId,
)
return films.firstOrNull()
}
override fun findAll(): List<Film> =
jdbc.query(
"""
@@ -1,5 +1,7 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.JellyfinEventRecord
import com.project.movienight.application.ports.output.JellyfinEventStorePort
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
import org.springframework.stereotype.Repository
@@ -7,16 +9,8 @@ import org.springframework.stereotype.Repository
@Repository
class JellyfinEventRepository(
private val jdbc: NamedParameterJdbcTemplate,
) {
fun save(
eventId: String,
serverId: String?,
eventType: String,
occurredAt: java.time.OffsetDateTime?,
jellyfinUserId: String?,
jellyfinItemId: String?,
payload: String?,
): Int {
) : JellyfinEventStorePort {
override fun save(event: JellyfinEventRecord): Boolean {
val sql =
"""
INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload)
@@ -26,20 +20,14 @@ class JellyfinEventRepository(
val params =
MapSqlParameterSource()
.addValue("eventId", eventId)
.addValue("serverId", serverId)
.addValue("eventType", eventType)
.addValue("occurredAt", occurredAt)
.addValue("jellyfinUserId", jellyfinUserId)
.addValue("jellyfinItemId", jellyfinItemId)
.addValue("payload", payload)
.addValue("eventId", event.eventId)
.addValue("serverId", event.serverId)
.addValue("eventType", event.eventType)
.addValue("occurredAt", event.occurredAt)
.addValue("jellyfinUserId", event.jellyfinUserId)
.addValue("jellyfinItemId", event.jellyfinItemId)
.addValue("payload", event.payload)
return jdbc.update(sql, params)
}
fun delete(eventId: String) {
val sql = "DELETE FROM jellyfin_events WHERE event_id = :eventId"
val params = MapSqlParameterSource().addValue("eventId", eventId)
jdbc.update(sql, params)
return jdbc.update(sql, params) == 1
}
}
@@ -4,6 +4,7 @@ import com.project.movienight.adapters.persistence.entity.UserEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import org.springframework.jdbc.core.JdbcTemplate
@@ -32,11 +33,9 @@ class UserRepository(
val entity =
if (existingUser != null) {
val existingEntity = existingUser.toEntity()
user.toEntity(
provider = existingEntity.provider?.let { AuthProvider.valueOf(it) },
providerId = existingEntity.providerId,
createdAt = existingEntity.createdAt,
provider = findProviderById(user.id),
providerId = findProviderIdById(user.id),
)
} else {
user.toEntity()
@@ -75,6 +74,70 @@ class UserRepository(
return user
}
override fun createOAuthUser(
user: User,
provider: AuthProvider,
providerId: String,
): User {
val entity = user.toEntity(provider = provider, providerId = providerId)
val updatedRows =
jdbc.update(
"""
UPDATE users
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
WHERE id = ?
""".trimIndent(),
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.id,
entity.name,
entity.email,
entity.provider,
entity.providerId,
entity.jellyfinUserId,
entity.createdAt,
)
}
return findById(user.id) ?: user
}
override fun linkOAuthAccount(
userId: UUID,
provider: AuthProvider,
providerId: String,
): User {
val updatedRows =
jdbc.update(
"""
UPDATE users
SET provider = ?, provider_id = ?
WHERE id = ?
""".trimIndent(),
provider.name,
providerId,
userId,
)
if (updatedRows == 0) {
throw EntityNotFoundException(entity = "User", id = userId.toString())
}
return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString())
}
override fun findById(id: UUID): User? {
val entities =
jdbc.query(
@@ -88,13 +151,31 @@ class UserRepository(
override fun findByEmail(email: String): User? {
val entities =
jdbc.query(
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?",
"""
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
FROM users
WHERE email = ?
""".trimIndent(),
userEntityRowMapper,
email,
)
return entities.firstOrNull()?.toDomain()
}
override fun findByJellyfinUserId(jellyfinUserId: String): User? {
val entities =
jdbc.query(
"""
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
FROM users
WHERE jellyfin_user_id = ?
""".trimIndent(),
userEntityRowMapper,
jellyfinUserId,
)
return entities.firstOrNull()?.toDomain()
}
override fun findAll(): List<User> =
jdbc
.query(
@@ -122,4 +203,21 @@ class UserRepository(
)
return entities.firstOrNull()?.toDomain()
}
private fun findProviderById(id: UUID): AuthProvider? =
jdbc
.query(
"SELECT provider FROM users WHERE id = ?",
{ rs: ResultSet, _: Int -> rs.getString("provider") },
id,
).firstOrNull()
?.let { AuthProvider.valueOf(it) }
private fun findProviderIdById(id: UUID): String? =
jdbc
.query(
"SELECT provider_id FROM users WHERE id = ?",
{ rs: ResultSet, _: Int -> rs.getString("provider_id") },
id,
).firstOrNull()
}
@@ -1,7 +1,5 @@
package com.project.movienight.adapters.security
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
@@ -59,12 +57,11 @@ class CustomOAuth2UserService(
if (userByEmail != null) {
log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail())
val entity =
userByEmail.toEntity(
provider = provider,
providerId = userInfo.getProviderId(),
)
userRepository.save(entity.toDomain())
userRepository.linkOAuthAccount(
userId = userByEmail.id,
provider = provider,
providerId = userInfo.getProviderId(),
)
} else {
log.debug("Creating new user for provider: {}", userInfo.getProvider())
val newUser =
@@ -72,14 +69,12 @@ class CustomOAuth2UserService(
id = idGenerator.generateId(),
name = userInfo.getName(),
email = userInfo.getEmail(),
library = null,
)
val entity =
newUser.toEntity(
provider = provider,
providerId = userInfo.getProviderId(),
)
userRepository.save(entity.toDomain())
userRepository.createOAuthUser(
user = newUser,
provider = provider,
providerId = userInfo.getProviderId(),
)
}
}
}
@@ -23,6 +23,11 @@ class SecurityConfiguration(
auth
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
.permitAll()
.requestMatchers(
"/api/integrations/jellyfin/events",
"/api/integrations/jellyfin/sync",
"/api/integrations/jellyfin/sync-state",
).permitAll()
.requestMatchers("/api/users/me")
.authenticated()
.requestMatchers("/api/**")
@@ -6,9 +6,12 @@ import com.project.movienight.domain.exception.EntityNotFoundException
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.MethodArgumentNotValidException
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestControllerAdvice
import org.springframework.web.server.ResponseStatusException
@RestControllerAdvice
class ApiExceptionHandler {
@@ -50,6 +53,44 @@ class ApiExceptionHandler {
)
}
@ExceptionHandler(MethodArgumentNotValidException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse {
val traceId = currentTraceId()
val details =
exception
.bindingResult
.fieldErrors
.joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" }
.ifBlank { "Invalid request" }
log.warn("Validation error: traceId='{}', message='{}'", traceId, details)
return ErrorResponse(
message = details,
traceId = traceId,
)
}
@ExceptionHandler(ResponseStatusException::class)
fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity<ErrorResponse> {
val traceId = currentTraceId()
log.warn(
"HTTP error: traceId='{}', status='{}', message='{}'",
traceId,
exception.statusCode,
exception.reason,
)
return ResponseEntity
.status(exception.statusCode)
.body(
ErrorResponse(
message = exception.reason ?: exception.message,
traceId = traceId,
),
)
}
@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleUnexpectedException(exception: Exception): ErrorResponse {
@@ -0,0 +1,10 @@
package com.project.movienight.adapters.web
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.model.ContentType
fun parseContentType(value: String): ContentType =
runCatching { ContentType.valueOf(value.uppercase()) }
.getOrElse { throw DomainException("Unsupported content type: $value") }
fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) }
@@ -4,13 +4,9 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
import com.project.movienight.adapters.web.dto.request.EditFilmRequest
import com.project.movienight.adapters.web.dto.response.FilmResponse
import com.project.movienight.application.ports.input.CreateFilmCommand
import com.project.movienight.application.ports.input.CreateFilmUseCase
import com.project.movienight.application.ports.input.DeleteFilmUseCase
import com.project.movienight.application.ports.input.EditFilmCommand
import com.project.movienight.application.ports.input.EditFilmUseCase
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
import com.project.movienight.application.ports.input.FilmUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
@@ -25,33 +21,22 @@ import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType =
runCatching {
com.project.movienight.domain.model.ContentType
.valueOf(this)
}.getOrDefault(com.project.movienight.domain.model.ContentType.FILM)
@RestController
@RequestMapping("/api/films")
class FilmController(
private val createFilmUseCase: CreateFilmUseCase,
private val editFilmUseCase: EditFilmUseCase,
private val deleteFilmUseCase: DeleteFilmUseCase,
private val getFilmByIdUseCase: GetFilmByIdUseCase,
private val getAllFilmsUseCase: GetAllFilmsUseCase,
private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase,
private val filmUseCase: FilmUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestBody request: CreateFilmRequest,
@Valid @RequestBody request: CreateFilmRequest,
): FilmResponse =
FilmResponse.fromDomain(
createFilmUseCase.create(
filmUseCase.create(
CreateFilmCommand(
title = request.title,
description = request.description,
contentType = request.contentType.toContentTypeOrFilm(),
contentType = parseContentType(request.contentType),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
@@ -68,16 +53,16 @@ class FilmController(
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@RequestBody request: EditFilmRequest,
@Valid @RequestBody request: EditFilmRequest,
): FilmResponse =
FilmResponse.fromDomain(
editFilmUseCase.edit(
filmUseCase.edit(
id = id,
command =
EditFilmCommand(
title = request.title,
description = request.description,
contentType = request.contentType.toContentTypeOrFilm(),
contentType = parseContentType(request.contentType),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
@@ -95,21 +80,21 @@ class FilmController(
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@PathVariable id: UUID,
) = deleteFilmUseCase.delete(id)
) = filmUseCase.delete(id)
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id))
@GetMapping
fun getAll(): List<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
fun getAll(): List<FilmResponse> = filmUseCase.getAll().map { FilmResponse.fromDomain(it) }
@GetMapping("/search")
fun searchByTitle(
@RequestParam title: String,
): ResponseEntity<FilmResponse> {
val film = searchFilmByTitleUseCase.searchByTitle(title)
val film = filmUseCase.searchByTitle(title)
return if (film != null) {
ResponseEntity.ok(FilmResponse.fromDomain(film))
} else {
@@ -1,28 +1,16 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse
import com.project.movienight.adapters.web.dto.response.FilmResponse
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import com.project.movienight.domain.exception.EntityNotFoundException
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.DeleteMapping
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.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
@@ -31,52 +19,29 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/library")
class FilmLibraryController(
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
private val getAllFilmsUseCase: GetAllFilmsUseCase,
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@PathVariable userId: UUID,
@RequestBody request: CreateFilmLibraryRequest,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
createFilmLibraryUseCase.create(
CreateFilmLibraryCommand(
userId = userId,
name = request.name,
),
),
)
@GetMapping
fun get(
@PathVariable userId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
getFilmLibraryUseCase.getLibrary(
GetFilmLibraryQuery(userId = userId),
),
)
@GetMapping("/entries")
fun list(
@PathVariable userId: UUID,
): List<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
): List<FilmLibraryEntryResponse> =
filmLibraryUseCase
.list(userId)
.map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
@GetMapping("/entries")
fun listEntries(
@PathVariable userId: UUID,
): List<FilmLibraryEntryResponse> = list(userId)
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun addFilm(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
addFilmToLibraryUseCase.addFilm(
): FilmLibraryEntryResponse =
FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.addFilm(
AddFilmToLibraryCommand(
userId = userId,
filmId = filmId,
@@ -88,9 +53,9 @@ class FilmLibraryController(
fun markViewed(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
markFilmViewedUseCase.markViewed(
): FilmLibraryEntryResponse =
FilmLibraryEntryResponse.fromDomain(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = userId,
filmId = filmId,
@@ -104,7 +69,7 @@ class FilmLibraryController(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
) {
removeFilmFromLibraryUseCase.removeFilm(
filmLibraryUseCase.removeFilm(
RemoveFilmFromLibraryCommand(
userId = userId,
filmId = filmId,
@@ -115,27 +80,5 @@ class FilmLibraryController(
@GetMapping("/available-films")
fun getAvailableFilms(
@PathVariable userId: UUID,
): List<FilmResponse> {
val userLibrary =
runCatching {
getFilmLibraryUseCase.getLibrary(
GetFilmLibraryQuery(userId = userId),
)
}.onFailure { exception ->
if (exception !is EntityNotFoundException) {
throw exception
}
}.getOrNull()
val allFilms = getAllFilmsUseCase.getAll()
val availableFilms =
if (userLibrary != null) {
allFilms.filter { it.id != userLibrary.filmId }
} else {
allFilms
}
return availableFilms.map { FilmResponse.fromDomain(it) }
}
): List<FilmResponse> = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) }
}
@@ -2,9 +2,9 @@ package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RateFilmUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
@@ -18,18 +18,17 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/ratings")
class FilmRatingController(
private val rateFilmUseCase: RateFilmUseCase,
private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
private val filmRatingUseCase: FilmRatingUseCase,
) {
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun rate(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
@RequestBody request: RateFilmRequest,
@Valid @RequestBody request: RateFilmRequest,
): FilmRatingResponse =
FilmRatingResponse.fromDomain(
rateFilmUseCase.rate(
filmRatingUseCase.rate(
RateFilmCommand(
userId = userId,
filmId = filmId,
@@ -42,5 +41,5 @@ class FilmRatingController(
@GetMapping
fun list(
@PathVariable userId: UUID,
): List<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
): List<FilmRatingResponse> = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
}
@@ -1,8 +1,10 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
import com.project.movienight.application.services.JellyfinEventService
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
import org.springframework.web.bind.annotation.PostMapping
@@ -11,12 +13,11 @@ 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 jellyfinEventService: JellyfinEventService,
private val jellyfinEventUseCase: JellyfinEventUseCase,
private val properties: JellyfinIntegrationProperties,
) {
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
@@ -25,17 +26,10 @@ class JellyfinEventsController(
@ResponseStatus(HttpStatus.OK)
fun receiveEvent(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@RequestBody request: JellyfinEventRequest,
@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")
}
}
requireJellyfinIntegrationEnabled(properties)
requireJellyfinPluginToken(properties, token)
log.debug(
"Received Jellyfin event {} for user {} item {}",
@@ -43,14 +37,16 @@ class JellyfinEventsController(
request.jellyfinUserId,
request.itemId,
)
jellyfinEventService.handleEvent(
eventId = request.eventId,
serverId = null,
eventType = request.eventType,
occurredAt = request.occurredAt,
jellyfinUserId = request.jellyfinUserId,
itemId = request.itemId,
payload = request.payload,
jellyfinEventUseCase.handle(
HandleJellyfinEventCommand(
eventId = request.eventId,
serverId = null,
eventType = request.eventType,
occurredAt = request.occurredAt,
jellyfinUserId = request.jellyfinUserId,
itemId = request.itemId,
payload = request.payload,
),
)
}
}
@@ -0,0 +1,20 @@
package com.project.movienight.adapters.web
import com.project.movienight.config.JellyfinIntegrationProperties
import org.springframework.http.HttpStatus
import org.springframework.web.server.ResponseStatusException
fun requireJellyfinIntegrationEnabled(properties: JellyfinIntegrationProperties) {
if (!properties.enabled) {
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
}
}
fun requireJellyfinPluginToken(
properties: JellyfinIntegrationProperties,
token: String?,
) {
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
}
}
@@ -1,21 +1,43 @@
package com.project.movienight.adapters.web
import com.project.movienight.application.services.JellyfinSyncService
import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
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
@RestController
@RequestMapping("/api/integrations/jellyfin")
class JellyfinSyncController(
private val jellyfinSyncService: JellyfinSyncService,
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
private val properties: JellyfinIntegrationProperties,
) {
@PostMapping("/sync")
fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
fun syncFromPlugin(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody request: JellyfinSyncRequest,
): JellyfinSyncSummary {
requireJellyfinIntegrationEnabled(properties)
requireJellyfinPluginToken(properties, token)
return jellyfinSyncUseCase.syncFromPlugin(request.toCommand())
}
@PostMapping("/pull-sync")
fun pullSyncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow()
@GetMapping("/sync-state")
fun syncState(): List<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
fun syncState(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
): List<JellyfinSyncState> {
requireJellyfinIntegrationEnabled(properties)
requireJellyfinPluginToken(properties, token)
return jellyfinSyncUseCase.getSyncStates()
}
}
@@ -9,7 +9,6 @@ import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -40,7 +39,7 @@ class RecommendationController(
.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
contentType = parseOptionalContentType(contentType),
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
@@ -1,16 +1,15 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.security.UserPrincipal
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
import com.project.movienight.adapters.web.dto.request.EditUserRequest
import com.project.movienight.adapters.web.dto.response.UserResponse
import com.project.movienight.application.ports.input.CreateUserCommand
import com.project.movienight.application.ports.input.CreateUserUseCase
import com.project.movienight.application.ports.input.DeleteUserUseCase
import com.project.movienight.application.ports.input.EditUserCommand
import com.project.movienight.application.ports.input.EditUserUseCase
import com.project.movienight.application.ports.input.GetAllUsersUseCase
import com.project.movienight.application.ports.input.GetUserByIdUseCase
import com.project.movienight.application.ports.input.UserUseCase
import jakarta.validation.Valid
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
@@ -25,19 +24,15 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users")
class UserController(
private val createUserUseCase: CreateUserUseCase,
private val editUserUseCase: EditUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
private val getUserByIdUseCase: GetUserByIdUseCase,
private val getAllUsersUseCase: GetAllUsersUseCase,
private val userUseCase: UserUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
fun create(
@RequestBody request: CreateUserRequest,
@Valid @RequestBody request: CreateUserRequest,
): UserResponse =
UserResponse.fromDomain(
createUserUseCase.create(
userUseCase.create(
CreateUserCommand(
name = request.name,
email = request.email,
@@ -46,20 +41,25 @@ class UserController(
)
@GetMapping
fun getAll(): List<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
fun getAll(): List<UserResponse> = userUseCase.getAll().map { UserResponse.fromDomain(it) }
@GetMapping("/me")
fun getMe(
@AuthenticationPrincipal principal: UserPrincipal,
): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId()))
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
): UserResponse = UserResponse.fromDomain(userUseCase.getById(id))
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@RequestBody request: EditUserRequest,
@Valid @RequestBody request: EditUserRequest,
): UserResponse =
UserResponse.fromDomain(
editUserUseCase.edit(
userUseCase.edit(
id = id,
command =
EditUserCommand(
@@ -73,5 +73,5 @@ class UserController(
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@PathVariable id: UUID,
) = deleteUserUseCase.delete(id)
) = userUseCase.delete(id)
}
@@ -2,10 +2,9 @@ package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
import com.project.movienight.domain.model.ContentType
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
@@ -17,16 +16,15 @@ import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/preferences")
class UserPreferencesController(
private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
private val userPreferencesUseCase: UserPreferencesUseCase,
) {
@PutMapping
fun upsert(
@PathVariable userId: UUID,
@RequestBody request: UpsertUserPreferencesRequest,
@Valid @RequestBody request: UpsertUserPreferencesRequest,
): UserPreferencesResponse =
UserPreferencesResponse.fromDomain(
upsertUserPreferencesUseCase.upsert(
userPreferencesUseCase.upsert(
UpsertUserPreferencesCommand(
userId = userId,
weightedGenres = request.weightedGenres,
@@ -35,13 +33,7 @@ class UserPreferencesController(
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes =
request.contentTypes.mapNotNull {
runCatching {
ContentType.valueOf(
it,
)
}.getOrNull()
},
request.contentTypes.map { parseContentType(it) },
),
),
)
@@ -49,5 +41,5 @@ class UserPreferencesController(
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
}
@@ -1,5 +0,0 @@
package com.project.movienight.adapters.web.dto.request
data class CreateFilmLibraryRequest(
val name: String = "My films",
)
@@ -1,14 +1,29 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CreateFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String,
@field:NotBlank
val description: String,
@field:NotBlank
val contentType: String = "FILM",
@field:Min(1888)
@field:Max(3000)
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
@field:Min(0)
@field:Max(10)
val imdbRating: Double? = null,
@field:Min(0)
@field:Max(10)
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
@@ -1,6 +1,15 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Email
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class CreateUserRequest(
@field:NotBlank
@field:Size(max = 255)
val name: String,
@field:Email
@field:NotBlank
@field:Size(max = 320)
val email: String,
)
@@ -1,14 +1,29 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class EditFilmRequest(
@field:NotBlank
@field:Size(max = 255)
val title: String,
@field:NotBlank
val description: String,
@field:NotBlank
val contentType: String = "FILM",
@field:Min(1888)
@field:Max(3000)
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
@field:Min(0)
@field:Max(10)
val imdbRating: Double? = null,
@field:Min(0)
@field:Max(10)
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
@@ -1,6 +1,12 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
data class EditUserRequest(
@field:NotBlank
@field:Size(max = 255)
val name: String,
@field:Size(max = 255)
val jellyfinUserId: String? = null,
)
@@ -1,18 +1,23 @@
package com.project.movienight.adapters.web.dto.request
import com.fasterxml.jackson.annotation.JsonProperty
import jakarta.validation.constraints.NotBlank
import java.time.OffsetDateTime
data class JellyfinEventRequest(
@JsonProperty("event_id")
@field:NotBlank
val eventId: String,
@JsonProperty("event_type")
@field:NotBlank
val eventType: String,
@JsonProperty("occurred_at")
val occurredAt: OffsetDateTime,
@JsonProperty("jellyfin_user_id")
@field:NotBlank
val jellyfinUserId: String,
@JsonProperty("item_id")
@field:NotBlank
val itemId: String,
@JsonProperty("payload_version")
val payloadVersion: Int = 1,
@@ -0,0 +1,55 @@
package com.project.movienight.adapters.web.dto.request
import com.project.movienight.application.ports.input.JellyfinPluginSyncCommand
import com.project.movienight.application.ports.input.JellyfinPluginSyncItem
import com.project.movienight.application.ports.input.JellyfinPluginUserState
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import java.time.OffsetDateTime
data class JellyfinSyncRequest(
@field:Valid
val items: List<JellyfinSyncItemRequest> = emptyList(),
) {
fun toCommand(): JellyfinPluginSyncCommand =
JellyfinPluginSyncCommand(
items = items.map { it.toCommand() },
)
}
data class JellyfinSyncItemRequest(
@field:NotBlank
val jellyfinItemId: String,
val title: String?,
val description: String?,
val year: Int?,
val genres: List<String> = emptyList(),
val imdbId: String?,
@field:Valid
val userStates: List<JellyfinUserStateRequest> = emptyList(),
) {
fun toCommand(): JellyfinPluginSyncItem =
JellyfinPluginSyncItem(
jellyfinItemId = jellyfinItemId,
title = title?.takeIf { it.isNotBlank() } ?: jellyfinItemId,
description = description,
year = year,
genres = genres,
imdbId = imdbId,
userStates = userStates.map { it.toCommand() },
)
}
data class JellyfinUserStateRequest(
@field:NotBlank
val jellyfinUserId: String,
val isViewed: Boolean = false,
val lastPlayedAt: OffsetDateTime? = null,
) {
fun toCommand(): JellyfinPluginUserState =
JellyfinPluginUserState(
jellyfinUserId = jellyfinUserId,
isViewed = isViewed,
lastPlayedAt = lastPlayedAt,
)
}
@@ -1,6 +1,13 @@
package com.project.movienight.adapters.web.dto.request
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import jakarta.validation.constraints.Size
data class RateFilmRequest(
@field:Min(1)
@field:Max(10)
val score: Int,
@field:Size(max = 2048)
val note: String? = null,
)
@@ -0,0 +1,26 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.FilmLibraryEntry
import java.time.LocalDateTime
import java.util.UUID
data class FilmLibraryEntryResponse(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val comment: String?,
val isViewed: Boolean,
val watchedAt: LocalDateTime?,
) {
companion object {
fun fromDomain(entry: FilmLibraryEntry): FilmLibraryEntryResponse =
FilmLibraryEntryResponse(
id = entry.id,
userId = entry.userId,
filmId = entry.filmId,
comment = entry.comment,
isViewed = entry.isViewed,
watchedAt = entry.watchedAt,
)
}
}
@@ -1,25 +0,0 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.FilmLibrary
import java.util.UUID
data class FilmLibraryResponse(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val comment: String?,
val isViewed: Boolean,
val watchedAt: java.time.LocalDateTime?,
) {
companion object {
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
FilmLibraryResponse(
id = filmLibrary.id,
userId = filmLibrary.userId,
filmId = filmLibrary.filmId,
comment = filmLibrary.comment,
isViewed = filmLibrary.isViewed,
watchedAt = filmLibrary.watchedAt,
)
}
}
@@ -1,20 +1,20 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry
import java.time.LocalDateTime
import java.util.UUID
interface CreateFilmLibraryUseCase {
fun create(command: CreateFilmLibraryCommand): FilmLibrary
}
interface FilmLibraryUseCase {
fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry
data class CreateFilmLibraryCommand(
val userId: UUID,
val name: String = "Мои фильмы",
)
fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry
interface AddFilmToLibraryUseCase {
fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry
fun list(userId: UUID): List<FilmLibraryEntry>
fun listAvailableFilms(userId: UUID): List<Film>
}
data class AddFilmToLibraryCommand(
@@ -22,34 +22,14 @@ data class AddFilmToLibraryCommand(
val filmId: UUID,
)
interface MarkFilmViewedUseCase {
fun markViewed(command: MarkFilmViewedCommand): FilmLibrary
}
data class MarkFilmViewedCommand(
val userId: UUID,
val filmId: UUID,
val watchedAt: LocalDateTime? = null,
)
interface RemoveFilmFromLibraryUseCase {
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary
}
data class RemoveFilmFromLibraryCommand(
val userId: UUID,
val filmId: UUID,
val libraryId: UUID? = null,
val entryId: UUID? = null,
)
interface GetFilmLibraryUseCase {
fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary
}
data class GetFilmLibraryQuery(
val userId: UUID,
)
interface ListFilmLibraryEntriesUseCase {
fun list(userId: UUID): List<FilmLibrary>
}
@@ -3,8 +3,10 @@ package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmRating
import java.util.UUID
interface RateFilmUseCase {
interface FilmRatingUseCase {
fun rate(command: RateFilmCommand): FilmRating
fun getRatings(userId: UUID): List<FilmRating>
}
data class RateFilmCommand(
@@ -13,7 +15,3 @@ data class RateFilmCommand(
val score: Int,
val note: String? = null,
)
interface GetFilmRatingsUseCase {
fun getRatings(userId: UUID): List<FilmRating>
}
@@ -4,8 +4,21 @@ import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film
import java.util.UUID
interface CreateFilmUseCase {
interface FilmUseCase {
fun create(command: CreateFilmCommand): Film
fun edit(
id: UUID,
command: EditFilmCommand,
): Film
fun delete(id: UUID)
fun getById(id: UUID): Film
fun getAll(): List<Film>
fun searchByTitle(title: String): Film?
}
data class CreateFilmCommand(
@@ -23,13 +36,6 @@ data class CreateFilmCommand(
val jellyfinLibraryId: String? = null,
)
interface EditFilmUseCase {
fun edit(
id: UUID,
command: EditFilmCommand,
): Film
}
data class EditFilmCommand(
val title: String,
val description: String,
@@ -44,19 +50,3 @@ data class EditFilmCommand(
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)
interface DeleteFilmUseCase {
fun delete(id: UUID)
}
interface GetFilmByIdUseCase {
fun getById(id: UUID): Film
}
interface GetAllFilmsUseCase {
fun getAll(): List<Film>
}
interface SearchFilmByTitleUseCase {
fun searchByTitle(title: String): Film?
}
@@ -0,0 +1,47 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import java.time.OffsetDateTime
interface JellyfinEventUseCase {
fun handle(command: HandleJellyfinEventCommand)
}
data class HandleJellyfinEventCommand(
val eventId: String,
val serverId: String?,
val eventType: String,
val occurredAt: OffsetDateTime,
val jellyfinUserId: String,
val itemId: String,
val payload: Map<String, Any>?,
)
interface JellyfinSyncUseCase {
fun syncNow(): JellyfinSyncSummary
fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary
fun getSyncStates(): List<JellyfinSyncState>
}
data class JellyfinPluginSyncCommand(
val items: List<JellyfinPluginSyncItem>,
)
data class JellyfinPluginSyncItem(
val jellyfinItemId: String,
val title: String,
val description: String?,
val year: Int?,
val genres: List<String>,
val imdbId: String?,
val userStates: List<JellyfinPluginUserState>,
)
data class JellyfinPluginUserState(
val jellyfinUserId: String,
val isViewed: Boolean,
val lastPlayedAt: OffsetDateTime?,
)
@@ -4,8 +4,10 @@ import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
interface UpsertUserPreferencesUseCase {
interface UserPreferencesUseCase {
fun upsert(command: UpsertUserPreferencesCommand): UserPreferences
fun get(userId: UUID): UserPreferences?
}
data class UpsertUserPreferencesCommand(
@@ -17,7 +19,3 @@ data class UpsertUserPreferencesCommand(
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
)
interface GetUserPreferencesUseCase {
fun get(userId: UUID): UserPreferences?
}
@@ -3,8 +3,19 @@ package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.User
import java.util.UUID
interface CreateUserUseCase {
interface UserUseCase {
fun create(command: CreateUserCommand): User
fun edit(
id: UUID,
command: EditUserCommand,
): User
fun delete(id: UUID)
fun getById(id: UUID): User
fun getAll(): List<User>
}
data class CreateUserCommand(
@@ -12,26 +23,7 @@ data class CreateUserCommand(
val email: String,
)
interface EditUserUseCase {
fun edit(
id: UUID,
command: EditUserCommand,
): User
}
data class EditUserCommand(
val name: String,
val jellyfinUserId: String? = null,
)
interface DeleteUserUseCase {
fun delete(id: UUID)
}
interface GetUserByIdUseCase {
fun getById(id: UUID): User
}
interface GetAllUsersUseCase {
fun getAll(): List<User>
}
@@ -0,0 +1,30 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
interface BusinessMetricsPort {
fun recordFilmCreated()
fun recordFilmEdited()
fun recordFilmDeleted()
fun recordFilmBlocked()
fun recordRecommendationRequest()
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType)
fun recordRatingSubmitted()
fun recordLibraryEvent()
fun recordJellyfinSync(summary: JellyfinSyncSummary)
fun recordJellyfinSyncFailure()
fun recordJellyfinUnmappedUser()
fun recordBackendWriteFailure()
}
@@ -0,0 +1,21 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.FilmLibraryEntry
import java.util.UUID
interface FilmLibraryEntryRepositoryPort {
fun save(entry: FilmLibraryEntry): FilmLibraryEntry
fun findById(id: UUID): FilmLibraryEntry?
fun findByUserId(userId: UUID): List<FilmLibraryEntry>
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibraryEntry?
fun findAll(): List<FilmLibraryEntry>
fun deleteById(id: UUID)
}
@@ -1,19 +0,0 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.FilmLibrary
import java.util.UUID
interface FilmLibraryRepositoryPort {
fun save(filmLibrary: FilmLibrary): FilmLibrary
fun findById(id: UUID): FilmLibrary?
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary?
fun findAll(): List<FilmLibrary>
fun deleteById(id: UUID)
}
@@ -10,8 +10,6 @@ interface FilmRepositoryPort {
fun findByJellyfinItemId(jellyfinItemId: String): Film?
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
fun findAll(): List<Film>
fun findByTitle(title: String): Film?
@@ -0,0 +1,30 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.ContentType
interface JellyfinCatalogPort {
fun fetchUsers(): List<JellyfinRemoteUser>
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot>
}
data class JellyfinRemoteUser(
val id: String,
val name: String,
)
data class JellyfinLibraryItemSnapshot(
val jellyfinItemId: String,
val title: String,
val description: String,
val contentType: ContentType,
val releaseYear: Int?,
val genres: List<String>,
val cast: List<String>,
val directors: List<String>,
val platformRating: Double?,
val imdbRating: Double?,
val externalUrl: String?,
val jellyfinLibraryId: String?,
val isPlayed: Boolean,
)
@@ -0,0 +1,17 @@
package com.project.movienight.application.ports.output
import java.time.OffsetDateTime
interface JellyfinEventStorePort {
fun save(event: JellyfinEventRecord): Boolean
}
data class JellyfinEventRecord(
val eventId: String,
val serverId: String?,
val eventType: String,
val occurredAt: OffsetDateTime?,
val jellyfinUserId: String?,
val jellyfinItemId: String?,
val payload: String?,
)
@@ -7,10 +7,24 @@ import java.util.UUID
interface UserRepositoryPort {
fun save(user: User): User
fun createOAuthUser(
user: User,
provider: AuthProvider,
providerId: String,
): User
fun linkOAuthAccount(
userId: UUID,
provider: AuthProvider,
providerId: String,
): User
fun findById(id: UUID): User?
fun findByEmail(email: String): User?
fun findByJellyfinUserId(jellyfinUserId: String): User?
fun findAll(): List<User>
fun deleteById(id: UUID)
@@ -1,46 +1,34 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase,
MarkFilmViewedUseCase,
RemoveFilmFromLibraryUseCase,
GetFilmLibraryUseCase,
ListFilmLibraryEntriesUseCase {
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
findByUserId(command.userId)?.let { return it }
throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
}
private val businessMetricsService: BusinessMetricsPort,
) : FilmLibraryUseCase {
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry {
ensureFilmExists(command.filmId)
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
if (existingEntry != null) {
val saved =
filmLibraryRepository.save(
filmLibraryEntryRepository.save(
existingEntry.copy(
isViewed = false,
watchedAt = null,
@@ -51,8 +39,8 @@ class FilmLibraryService(
}
val saved =
filmLibraryRepository.save(
FilmLibrary(
filmLibraryEntryRepository.save(
FilmLibraryEntry(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
@@ -65,33 +53,35 @@ class FilmLibraryService(
return saved
}
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
val existingLibrary =
if (command.libraryId != null) {
filmLibraryRepository.findById(command.libraryId)
?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry {
val existingEntry =
if (command.entryId != null) {
filmLibraryEntryRepository.findById(command.entryId)
?: throw EntityNotFoundException(entity = "Film library entry", id = command.entryId.toString())
} else {
findByUserAndFilmId(command.userId, command.filmId)
?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString())
filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
?: throw EntityNotFoundException(entity = "Film library entry", id = command.filmId.toString())
}
if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
if (existingEntry.userId != command.userId || existingEntry.filmId != command.filmId) {
throw DomainException("Film with id ${command.filmId} not found in user's library")
}
filmLibraryRepository.deleteById(existingLibrary.id)
filmLibraryEntryRepository.deleteById(existingEntry.id)
businessMetricsService.recordLibraryEvent()
return existingLibrary
return existingEntry
}
override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
override fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry {
ensureFilmExists(command.filmId)
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
val saved =
if (existingEntry == null) {
filmLibraryRepository.save(
FilmLibrary(
filmLibraryEntryRepository.save(
FilmLibraryEntry(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
@@ -101,7 +91,7 @@ class FilmLibraryService(
),
)
} else {
filmLibraryRepository.save(
filmLibraryEntryRepository.save(
existingEntry.copy(
isViewed = true,
watchedAt = watchedAt,
@@ -112,17 +102,14 @@ class FilmLibraryService(
return saved
}
override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary =
findByUserId(query.userId)
?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
override fun list(userId: UUID): List<FilmLibraryEntry> = filmLibraryEntryRepository.findByUserId(userId)
override fun list(userId: UUID): List<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
override fun listAvailableFilms(userId: UUID): List<Film> {
val libraryFilmIds = list(userId).map { it.filmId }.toSet()
return filmRepository.findAll().filter { it.id !in libraryFilmIds }
}
private fun findByUserId(userId: UUID): FilmLibrary? =
filmLibraryRepository.findAll().firstOrNull { it.userId == userId }
private fun findByUserAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId }
private fun ensureFilmExists(filmId: UUID) {
filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
}
}
@@ -1,9 +1,8 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
import com.project.movienight.application.ports.input.FilmRatingUseCase
import com.project.movienight.application.ports.input.RateFilmCommand
import com.project.movienight.application.ports.input.RateFilmUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -19,9 +18,8 @@ class FilmRatingService(
private val filmRepository: FilmRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : RateFilmUseCase,
GetFilmRatingsUseCase {
private val businessMetricsService: BusinessMetricsPort,
) : FilmRatingUseCase {
override fun rate(command: RateFilmCommand): FilmRating {
if (command.score !in 1..10) {
throw DomainException("Film rating score must be between 1 and 10")
@@ -1,22 +1,16 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CreateFilmCommand
import com.project.movienight.application.ports.input.CreateFilmUseCase
import com.project.movienight.application.ports.input.DeleteFilmUseCase
import com.project.movienight.application.ports.input.EditFilmCommand
import com.project.movienight.application.ports.input.EditFilmUseCase
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
import com.project.movienight.application.ports.input.FilmUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.config.FilmServiceProperties
import com.project.movienight.domain.exception.BlockedValueException
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film
import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import io.micrometer.core.annotation.Timed
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
@@ -26,89 +20,81 @@ class FilmService(
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val filmConfig: FilmServiceProperties,
private val meterRegistry: MeterRegistry,
) : CreateFilmUseCase,
EditFilmUseCase,
DeleteFilmUseCase,
GetFilmByIdUseCase,
GetAllFilmsUseCase,
SearchFilmByTitleUseCase {
private val businessMetricsService: BusinessMetricsPort,
) : FilmUseCase {
private val log = LoggerFactory.getLogger(javaClass)
@Timed(
value = "business_films_create_duration_seconds",
description = "Film creation duration",
)
override fun create(command: CreateFilmCommand): Film {
val sample = Timer.start(meterRegistry)
log.debug(
"Create film request received: title='{}', descriptionLength={}",
command.title,
command.description.length,
)
try {
log.debug(
"Create film request received: title='{}', descriptionLength={}",
command.title,
command.description.length,
if (filmConfig.isBlocked(command.title)) {
log.debug("Create film blocked by title policy: title='{}'", command.title)
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "title")
}
if (filmConfig.isBlocked(command.description)) {
log.debug("Create film blocked by description policy")
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "description")
}
val film =
Film(
id = idGenerator.generateId(),
title = command.title,
description = command.description,
contentType = command.contentType,
releaseYear = command.releaseYear,
genres = command.genres,
cast = command.cast,
directors = command.directors,
imdbRating = command.imdbRating,
platformRating = command.platformRating,
externalUrl = command.externalUrl,
jellyfinItemId = command.jellyfinItemId,
jellyfinLibraryId = command.jellyfinLibraryId,
)
if (filmConfig.isBlocked(command.title)) {
log.debug("Create film blocked by title policy: title='{}'", command.title)
filmBlockedCounter.increment()
throw BlockedValueException(target = "Film", field = "title")
}
if (filmConfig.isBlocked(command.description)) {
log.debug("Create film blocked by description policy")
filmBlockedCounter.increment()
throw BlockedValueException(target = "Film", field = "description")
}
val film =
Film(
id = idGenerator.generateId(),
title = command.title,
description = command.description,
contentType = command.contentType,
releaseYear = command.releaseYear,
genres = command.genres,
cast = command.cast,
directors = command.directors,
imdbRating = command.imdbRating,
platformRating = command.platformRating,
externalUrl = command.externalUrl,
jellyfinItemId = command.jellyfinItemId,
jellyfinLibraryId = command.jellyfinLibraryId,
)
val saved = filmRepository.save(film)
filmCreatedCounter.increment()
return saved
} finally {
sample.stop(createFilmTimer)
}
val saved = filmRepository.save(film)
businessMetricsService.recordFilmCreated()
return saved
}
@Timed(
value = "business_films_edit_duration_seconds",
description = "Film edit duration",
)
override fun edit(
id: UUID,
command: EditFilmCommand,
): Film {
val sample = Timer.start(meterRegistry)
log.debug("Edit film with id: {}", id)
try {
log.debug("Edit film with id: {}", id)
if (filmConfig.isBlocked(command.title)) {
log.debug("Edit film blocked by title policy: title='{}'", command.title)
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "title")
}
if (filmConfig.isBlocked(command.description)) {
log.debug("Edit film blocked by description policy")
businessMetricsService.recordFilmBlocked()
throw BlockedValueException(target = "Film", field = "description")
}
if (filmConfig.isBlocked(command.title)) {
log.debug("Edit film blocked by title policy: title='{}'", command.title)
filmBlockedCounter.increment()
throw BlockedValueException(target = "Film", field = "title")
}
if (filmConfig.isBlocked(command.description)) {
log.debug("Edit film blocked by description policy")
filmBlockedCounter.increment()
throw BlockedValueException(target = "Film", field = "description")
}
val film =
filmRepository.findById(id)
?: throw EntityNotFoundException(entity = "Film", id = id.toString())
var film = filmRepository.findById(id)
if (film == null) {
log.debug("Film not found for edit: id='{}'", id)
throw EntityNotFoundException(entity = "Film", id = id.toString())
}
film =
val saved =
filmRepository.save(
film.copy(
title = command.title,
description = command.description,
@@ -122,37 +108,30 @@ class FilmService(
externalUrl = command.externalUrl,
jellyfinItemId = command.jellyfinItemId,
jellyfinLibraryId = command.jellyfinLibraryId,
)
val saved = filmRepository.save(film)
filmEditedCounter.increment()
return saved
} finally {
sample.stop(editFilmTimer)
}
),
)
businessMetricsService.recordFilmEdited()
return saved
}
@Timed(
value = "business_films_delete_duration_seconds",
description = "Film deletion duration",
)
override fun delete(id: UUID) {
val sample = Timer.start(meterRegistry)
log.debug("Delete film with id: {}", id)
try {
log.debug("Delete film with id: {}", id)
val film = filmRepository.findById(id)
val film = filmRepository.findById(id)
if (film == null) {
log.debug("Film not found for delete: id='{}'", id)
throw EntityNotFoundException(entity = "Film", id = id.toString())
}
filmRepository.deleteById(id)
filmDeletedCounter.increment()
log.info("Film deleted: id='{}'", id)
} finally {
sample.stop(deleteFilmTimer)
if (film == null) {
log.debug("Film not found for delete: id='{}'", id)
throw EntityNotFoundException(entity = "Film", id = id.toString())
}
filmRepository.deleteById(id)
businessMetricsService.recordFilmDeleted()
log.info("Film deleted: id='{}'", id)
}
override fun getById(id: UUID): Film =
@@ -161,46 +140,4 @@ class FilmService(
override fun getAll(): List<Film> = filmRepository.findAll()
override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title)
private val filmCreatedCounter =
Counter
.builder("film_created_total")
.description("Total number of created films")
.register(meterRegistry)
private val filmEditedCounter =
Counter
.builder("film_edited_total")
.description("Total number of successfully edited films")
.register(meterRegistry)
private val filmDeletedCounter =
Counter
.builder("film_deleted_total")
.description("Total number of successfully deleted films")
.register(meterRegistry)
private val filmBlockedCounter =
Counter
.builder("films.blocked")
.description("Total blocked film operations")
.register(meterRegistry)
private val createFilmTimer =
Timer
.builder("films.create.duration")
.description("Film creation duration")
.register(meterRegistry)
private val editFilmTimer =
Timer
.builder("films.edit.duration")
.description("Film edit duration")
.register(meterRegistry)
private val deleteFilmTimer =
Timer
.builder("films.delete.duration")
.description("Film deletion duration")
.register(meterRegistry)
}
@@ -1,79 +1,73 @@
package com.project.movienight.application.services
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository
import com.project.movienight.application.ports.input.FilmLibraryUseCase
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
import com.project.movienight.application.ports.input.JellyfinEventUseCase
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.JellyfinEventRecord
import com.project.movienight.application.ports.output.JellyfinEventStorePort
import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service
import java.time.OffsetDateTime
import org.springframework.transaction.annotation.Transactional
@Service
class JellyfinEventService(
private val jellyfinEventRepository: JellyfinEventRepository,
private val jellyfinEventStore: JellyfinEventStorePort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val filmLibraryUseCase: FilmLibraryUseCase,
private val objectMapper: ObjectMapper,
private val businessMetricsService: BusinessMetricsService,
) {
private val businessMetricsService: BusinessMetricsPort,
) : JellyfinEventUseCase {
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
fun handleEvent(
eventId: String,
serverId: String?,
eventType: String,
occurredAt: OffsetDateTime,
jellyfinUserId: String,
itemId: String,
payload: Map<String, Any>?,
) {
val payloadJson = payload?.let { objectMapper.writeValueAsString(it) }
@Transactional
override fun handle(command: HandleJellyfinEventCommand) {
val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) }
val inserted =
jellyfinEventRepository.save(
eventId = eventId,
serverId = serverId,
eventType = eventType,
occurredAt = occurredAt,
jellyfinUserId = jellyfinUserId,
jellyfinItemId = itemId,
payload = payloadJson,
jellyfinEventStore.save(
JellyfinEventRecord(
eventId = command.eventId,
serverId = command.serverId,
eventType = command.eventType,
occurredAt = command.occurredAt,
jellyfinUserId = command.jellyfinUserId,
jellyfinItemId = command.itemId,
payload = payloadJson,
),
)
if (inserted != 1) {
if (!inserted) {
return
}
try {
if (playbackEventTypes.contains(eventType)) {
val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
if (playbackEventTypes.contains(command.eventType)) {
val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId)
if (localUser == null) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordJellyfinUnmappedUser()
return
}
val film = filmRepository.findByJellyfinItemId(itemId)
val film = filmRepository.findByJellyfinItemId(command.itemId)
if (film == null) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordBackendWriteFailure()
return
}
markFilmViewedUseCase.markViewed(
filmLibraryUseCase.markViewed(
MarkFilmViewedCommand(
userId = localUser.id,
filmId = film.id,
watchedAt = occurredAt.toLocalDateTime(),
watchedAt = command.occurredAt.toLocalDateTime(),
),
)
businessMetricsService.recordLibraryEvent()
}
} catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordBackendWriteFailure()
throw ex
}
@@ -1,18 +1,19 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot
import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.input.JellyfinPluginSyncCommand
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.JellyfinCatalogPort
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
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.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import org.springframework.scheduling.annotation.Scheduled
@@ -20,18 +21,19 @@ import org.springframework.stereotype.Service
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
import java.util.UUID
@Service
class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties,
private val jellyfinApiClient: JellyfinApiClient,
private val jellyfinCatalog: JellyfinCatalogPort,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) {
private val businessMetricsService: BusinessMetricsPort,
) : JellyfinSyncUseCase {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
fun scheduledSync() {
if (properties.enabled) {
@@ -39,13 +41,99 @@ class JellyfinSyncService(
}
}
fun syncNow(): JellyfinSyncSummary {
override fun syncNow(): JellyfinSyncSummary {
if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
}
return try {
runSync()
} catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
businessMetricsService.recordJellyfinSyncFailure()
throw ex
}
}
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
override fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary {
val startedAt = Instant.now()
val remoteUsers = jellyfinApiClient.fetchUsers()
val localUsersByJellyfinId =
userRepository
.findAll()
.mapNotNull { user ->
user.jellyfinUserId?.let { it to user }
}.toMap()
val syncedItemCountsByUser = mutableMapOf<UUID, Int>()
var skippedUsers = 0
command.items.forEach { item ->
val savedFilm =
upsertFilm(
JellyfinLibraryItemSnapshot(
jellyfinItemId = item.jellyfinItemId,
title = item.title,
description = item.description.orEmpty(),
contentType = ContentType.FILM,
releaseYear = item.year,
genres = item.genres,
cast = emptyList(),
directors = emptyList(),
platformRating = null,
imdbRating = null,
externalUrl = item.imdbId?.let { imdbUrl(it) },
jellyfinLibraryId = null,
isPlayed = false,
),
)
item.userStates.forEach { state ->
val localUser = localUsersByJellyfinId[state.jellyfinUserId]
if (localUser == null) {
skippedUsers += 1
return@forEach
}
syncedItemCountsByUser.merge(localUser.id, 1, Int::plus)
if (state.isViewed) {
markFilmViewed(
userId = localUser.id,
filmId = savedFilm.id,
watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(),
)
}
}
}
val now = LocalDateTime.now()
syncedItemCountsByUser.forEach { (userId, syncedItemCount) ->
syncStateRepository.save(
JellyfinSyncState(
userId = userId,
lastSyncedAt = now,
lastSuccessfulSyncAt = now,
lastError = null,
syncedItemCount = syncedItemCount,
),
)
}
val summary =
JellyfinSyncSummary(
syncedUsers = syncedItemCountsByUser.size,
skippedUsers = skippedUsers,
syncedItems = command.items.size,
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
)
businessMetricsService.recordJellyfinSync(summary)
return summary
}
private fun runSync(): JellyfinSyncSummary {
val startedAt = Instant.now()
val remoteUsers = jellyfinCatalog.fetchUsers()
val localUsersByJellyfinId =
userRepository
.findAll()
@@ -64,7 +152,7 @@ class JellyfinSyncService(
return@forEach
}
val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id)
val items = jellyfinCatalog.fetchLibraryItems(remoteUser.id)
items.forEach { item ->
syncItem(localUser.id, item)
syncedItems += 1
@@ -94,12 +182,22 @@ class JellyfinSyncService(
return summary
}
fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
private fun syncItem(
userId: java.util.UUID,
userId: UUID,
item: JellyfinLibraryItemSnapshot,
) {
val savedFilm = upsertFilm(item)
if (item.isPlayed) {
markFilmViewed(
userId = userId,
filmId = savedFilm.id,
watchedAt = LocalDateTime.now(),
)
}
}
private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film {
val film =
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
title = item.title,
@@ -130,24 +228,32 @@ class JellyfinSyncService(
jellyfinLibraryId = item.jellyfinLibraryId,
)
val savedFilm = filmRepository.save(film)
return filmRepository.save(film)
}
if (item.isPlayed) {
val watchedAt = LocalDateTime.now()
val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id)
filmLibraryRepository.save(
existingEntry?.copy(
isViewed = true,
watchedAt = watchedAt,
) ?: FilmLibrary(
id = idGenerator.generateId(),
userId = userId,
filmId = savedFilm.id,
comment = null,
isViewed = true,
watchedAt = watchedAt,
),
)
}
private fun markFilmViewed(
userId: UUID,
filmId: UUID,
watchedAt: LocalDateTime,
) {
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
filmLibraryEntryRepository.save(
existingEntry?.copy(
isViewed = true,
watchedAt = watchedAt,
) ?: FilmLibraryEntry(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
comment = null,
isViewed = true,
watchedAt = watchedAt,
),
)
}
private fun imdbUrl(imdbId: String): String {
val normalized = imdbId.trim().lowercase().let { if (it.startsWith("tt")) it else "tt$it" }
return "https://www.imdb.com/title/$normalized/"
}
}
@@ -3,7 +3,7 @@ package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -11,7 +11,7 @@ import com.project.movienight.application.ports.output.UserPreferencesRepository
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
@@ -25,7 +25,7 @@ class RecommendationOnboardingService(
private val filmRepository: FilmRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator,
) : CompleteRecommendationOnboardingUseCase {
@@ -125,12 +125,12 @@ class RecommendationOnboardingService(
userId: UUID,
filmId: UUID,
isViewed: Boolean,
): FilmLibrary {
): FilmLibraryEntry {
val watchedAt = LocalDateTime.now().takeIf { isViewed }
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
return filmLibraryRepository.save(
val existing = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
return filmLibraryEntryRepository.save(
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
?: FilmLibrary(
?: FilmLibraryEntry(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
@@ -1,13 +1,13 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
import com.project.movienight.application.ports.input.RecommendationQuery
import com.project.movienight.application.ports.input.RejectRecommendationCommand
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
@@ -17,7 +17,7 @@ import com.project.movienight.application.ports.output.UserRecommendationWeights
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
@@ -34,14 +34,14 @@ import kotlin.math.sqrt
@Service
class RecommendationService(
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val userRepository: UserRepositoryPort,
private val recommendationEventRepository: RecommendationEventRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
private val businessMetricsService: BusinessMetricsPort,
) : GetRecommendationsUseCase,
AcceptRecommendationUseCase,
RejectRecommendationUseCase {
@@ -54,7 +54,7 @@ class RecommendationService(
val preferences = userPreferencesRepository.findByUserId(query.userId)
val ratings = filmRatingRepository.findByUserId(query.userId)
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
val libraryEntries = filmLibraryEntryRepository.findByUserId(query.userId)
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
val films = filmRepository.findAll()
@@ -278,7 +278,7 @@ class RecommendationService(
private fun buildUserProfile(
preferences: UserPreferences?,
ratings: List<FilmRating>,
libraryEntries: List<FilmLibrary>,
libraryEntries: List<FilmLibraryEntry>,
filmsById: Map<UUID, Film>,
weights: UserRecommendationWeights,
): SparseVector {
@@ -1,8 +1,7 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
import com.project.movienight.application.ports.input.UserPreferencesUseCase
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
import com.project.movienight.domain.model.UserPreferences
import org.springframework.stereotype.Service
@@ -10,8 +9,7 @@ import org.springframework.stereotype.Service
@Service
class UserPreferencesService(
private val userPreferencesRepository: UserPreferencesRepositoryPort,
) : UpsertUserPreferencesUseCase,
GetUserPreferencesUseCase {
) : UserPreferencesUseCase {
override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences =
userPreferencesRepository.save(
UserPreferences(
@@ -1,12 +1,8 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CreateUserCommand
import com.project.movienight.application.ports.input.CreateUserUseCase
import com.project.movienight.application.ports.input.DeleteUserUseCase
import com.project.movienight.application.ports.input.EditUserCommand
import com.project.movienight.application.ports.input.EditUserUseCase
import com.project.movienight.application.ports.input.GetAllUsersUseCase
import com.project.movienight.application.ports.input.GetUserByIdUseCase
import com.project.movienight.application.ports.input.UserUseCase
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.UserServiceProperties
@@ -21,11 +17,7 @@ class UserService(
private val userRepository: UserRepositoryPort,
private val idGenerator: IdGenerator,
private val userConfig: UserServiceProperties,
) : CreateUserUseCase,
EditUserUseCase,
DeleteUserUseCase,
GetUserByIdUseCase,
GetAllUsersUseCase {
) : UserUseCase {
override fun create(command: CreateUserCommand): User {
if (userConfig.isBlocked(command.name)) {
throw BlockedValueException(target = "User", field = "name")
@@ -36,7 +28,6 @@ class UserService(
id = idGenerator.generateId(),
name = command.name,
email = command.email,
library = null,
jellyfinUserId = null,
)
return userRepository.save(user)
@@ -6,7 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
data class JellyfinIntegrationProperties(
val enabled: Boolean = false,
val baseUrl: String = "",
val webUrl: String = "",
val webUrl: String = baseUrl,
val apiKey: String = "",
val syncIntervalMs: Long = 1_800_000,
val requestTimeoutMs: Long = 20_000,
@@ -0,0 +1,12 @@
package com.project.movienight.config
import io.micrometer.core.aop.TimedAspect
import io.micrometer.core.instrument.MeterRegistry
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
class MetricsConfiguration {
@Bean
fun timedAspect(meterRegistry: MeterRegistry): TimedAspect = TimedAspect(meterRegistry)
}
@@ -3,7 +3,7 @@ package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class FilmLibrary(
data class FilmLibraryEntry(
val id: UUID,
val userId: UUID,
val filmId: UUID,
@@ -6,7 +6,6 @@ data class User(
val id: UUID,
val name: String,
val email: String,
val library: FilmLibrary?,
val preferences: UserPreferences? = null,
val jellyfinUserId: String? = null,
)
+1
View File
@@ -101,6 +101,7 @@ integrations:
base-url: ${JELLYFIN_BASE_URL:}
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
api-key: ${JELLYFIN_API_KEY:}
plugin-token: ${JELLYFIN_PLUGIN_TOKEN:}
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
+59
View File
@@ -6,12 +6,26 @@ erDiagram
UUID id PK
VARCHAR name
VARCHAR email
VARCHAR provider
VARCHAR provider_id
VARCHAR jellyfin_user_id
TIMESTAMP created_at
}
films {
UUID id PK
VARCHAR title
TEXT description
VARCHAR content_type
INT release_year
TEXT genres
TEXT cast_members
TEXT directors
DOUBLE imdb_rating
DOUBLE platform_rating
TEXT external_url
VARCHAR jellyfin_item_id
VARCHAR jellyfin_library_id
}
favorites {
@@ -20,8 +34,53 @@ erDiagram
UUID film_id FK
VARCHAR comment
BOOLEAN is_viewed
TIMESTAMP watched_at
}
user_preferences {
UUID user_id PK,FK
TEXT weighted_genres
TEXT plot_types
TEXT eras
TEXT cast_and_directors
TEXT moods
TEXT content_types
}
film_ratings {
UUID id PK
UUID user_id FK
UUID film_id FK
INT score
VARCHAR note
TIMESTAMP created_at
TIMESTAMP updated_at
}
jellyfin_events {
VARCHAR event_id PK
VARCHAR server_id
VARCHAR event_type
TIMESTAMP occurred_at
VARCHAR jellyfin_user_id
VARCHAR jellyfin_item_id
JSON payload
TIMESTAMP created_at
}
jellyfin_sync_state {
UUID user_id PK,FK
TIMESTAMP last_synced_at
TIMESTAMP last_successful_sync_at
TEXT last_error
INT synced_item_count
TIMESTAMP updated_at
}
users ||--o{ favorites : has
films ||--o{ favorites : linked
users ||--o{ film_ratings : rates
films ||--o{ film_ratings : rated
users ||--|| user_preferences : configures
users ||--|| jellyfin_sync_state : syncs
```
@@ -0,0 +1,11 @@
DROP TABLE IF EXISTS public.ratings;
ALTER TABLE public.users
DROP COLUMN IF EXISTS jellyfin_id;
ALTER TABLE public.films
DROP COLUMN IF EXISTS jellyfin_id;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_jellyfin_user_id ON public.users(jellyfin_user_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_films_jellyfin_item_id ON public.films(jellyfin_item_id);
CREATE INDEX IF NOT EXISTS idx_films_jellyfin_library_id ON public.films(jellyfin_library_id);
@@ -0,0 +1,17 @@
package com.project.movienight
import org.junit.jupiter.api.Test
import kotlin.test.assertNotNull
class ClassLoaderTest {
@Test
fun `can load OAuth2ClientProperties class`() {
val clazz =
Class.forName(
"org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties",
)
assertNotNull(clazz)
println("Successfully loaded: ${clazz.name}")
println("ClassLoader: ${clazz.classLoader}")
}
}
@@ -26,7 +26,6 @@ class UserEntityMappingTest {
assertEquals(entity.id, user.id)
assertEquals(entity.name, user.name)
assertEquals(entity.email, user.email)
assertNull(user.library)
assertNull(user.jellyfinUserId)
}
@@ -37,7 +36,6 @@ class UserEntityMappingTest {
id = UUID.randomUUID(),
name = "Jane",
email = "jane@mail.com",
library = null,
)
val entity = user.toEntity(AuthProvider.YANDEX, "yandex456")
@@ -56,7 +54,6 @@ class UserEntityMappingTest {
id = UUID.randomUUID(),
name = "Bob",
email = "bob@mail.com",
library = null,
)
val entity = user.toEntity()
@@ -72,7 +69,6 @@ class UserEntityMappingTest {
id = UUID.randomUUID(),
name = "Alice",
email = "alice@email.com",
library = null,
)
val mapped = original.toEntity().toDomain()
@@ -1,7 +1,7 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.User
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@@ -19,9 +19,9 @@ import java.util.UUID
@SpringBootTest
@ActiveProfiles("test")
class FilmLibraryRepositoryIntegrationTest {
class FilmLibraryEntryRepositoryIntegrationTest {
@Autowired
private lateinit var filmLibraryRepository: FilmLibraryRepository
private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepository
@Autowired
private lateinit var userRepository: UserRepository
@@ -62,7 +62,7 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should save new film library entry and return saved entry`() {
val entry =
FilmLibrary(
FilmLibraryEntry(
id = UUID.randomUUID(),
userId = testUser.id,
filmId = testFilm.id,
@@ -70,7 +70,7 @@ class FilmLibraryRepositoryIntegrationTest {
isViewed = false,
)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertEquals(entry.id, savedEntry.id)
@@ -83,17 +83,17 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should update existing film library entry`() {
val entryId = UUID.randomUUID()
val originalEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false)
filmLibraryRepository.save(originalEntry)
val originalEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false)
filmLibraryEntryRepository.save(originalEntry)
val updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true)
val result = filmLibraryRepository.save(updatedEntry)
val updatedEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true)
val result = filmLibraryEntryRepository.save(updatedEntry)
assertEquals(entryId, result.id)
assertEquals("Уже посмотрел, потрясающе!", result.comment)
assertTrue(result.isViewed)
val foundEntry = filmLibraryRepository.findById(entryId)
val foundEntry = filmLibraryEntryRepository.findById(entryId)
assertNotNull(foundEntry)
assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment)
assertTrue(foundEntry?.isViewed ?: false)
@@ -101,10 +101,10 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should find film library entry by id`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false)
filmLibraryEntryRepository.save(entry)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNotNull(foundEntry)
assertEquals(entry.id, foundEntry?.id)
@@ -118,22 +118,22 @@ class FilmLibraryRepositoryIntegrationTest {
fun `should return null when film library entry not found by id`() {
val nonExistentId = UUID.randomUUID()
val foundEntry = filmLibraryRepository.findById(nonExistentId)
val foundEntry = filmLibraryEntryRepository.findById(nonExistentId)
assertNull(foundEntry)
}
@Test
fun `should find all film library entries`() {
val entry1 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false)
val entry2 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true)
val entry3 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
val entry1 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false)
val entry2 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true)
val entry3 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
filmLibraryRepository.save(entry1)
filmLibraryRepository.save(entry2)
filmLibraryRepository.save(entry3)
filmLibraryEntryRepository.save(entry1)
filmLibraryEntryRepository.save(entry2)
filmLibraryEntryRepository.save(entry3)
val allEntries = filmLibraryRepository.findAll()
val allEntries = filmLibraryEntryRepository.findAll()
assertEquals(3, allEntries.size)
assertTrue(allEntries.any { it.id == entry1.id })
@@ -143,19 +143,19 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should return empty list when no film library entries exist`() {
val allEntries = filmLibraryRepository.findAll()
val allEntries = filmLibraryEntryRepository.findAll()
assertTrue(allEntries.isEmpty())
}
@Test
fun `should delete film library entry by id`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false)
filmLibraryEntryRepository.save(entry)
filmLibraryRepository.deleteById(entry.id)
filmLibraryEntryRepository.deleteById(entry.id)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNull(foundEntry)
}
@@ -163,14 +163,14 @@ class FilmLibraryRepositoryIntegrationTest {
fun `should not throw exception when deleting non-existent entry`() {
val nonExistentId = UUID.randomUUID()
filmLibraryRepository.deleteById(nonExistentId)
filmLibraryEntryRepository.deleteById(nonExistentId)
}
@Test
fun `should save entry with null comment`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertNull(savedEntry.comment)
@@ -178,9 +178,9 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should save entry with isViewed true`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertTrue(savedEntry.isViewed)
@@ -188,9 +188,9 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should save entry with isViewed false`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false)
val savedEntry = filmLibraryRepository.save(entry)
val savedEntry = filmLibraryEntryRepository.save(entry)
assertNotNull(savedEntry)
assertFalse(savedEntry.isViewed)
@@ -198,23 +198,23 @@ class FilmLibraryRepositoryIntegrationTest {
@Test
fun `should cascade delete entries when user is deleted`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false)
filmLibraryEntryRepository.save(entry)
userRepository.deleteById(testUser.id)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNull(foundEntry)
}
@Test
fun `should cascade delete entries when film is deleted`() {
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false)
filmLibraryRepository.save(entry)
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false)
filmLibraryEntryRepository.save(entry)
filmRepository.deleteById(testFilm.id)
val foundEntry = filmLibraryRepository.findById(entry.id)
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
assertNull(foundEntry)
}
}
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
@@ -46,7 +47,6 @@ class UserRepositoryIntegrationTest {
id = UUID.randomUUID(),
name = "John Doe",
email = "john@example.com",
library = null,
)
val savedUser = userRepository.save(user)
@@ -61,10 +61,10 @@ class UserRepositoryIntegrationTest {
fun `should update existing user`() {
// given
val userId = UUID.randomUUID()
val originalUser = User(userId, "John Doe", "john@example.com", null)
val originalUser = User(userId, "John Doe", "john@example.com")
userRepository.save(originalUser)
val updatedUser = User(userId, "Jane Doe", "jane@example.com", null)
val updatedUser = User(userId, "Jane Doe", "jane@example.com")
val result = userRepository.save(updatedUser)
assertEquals(userId, result.id)
@@ -80,7 +80,7 @@ class UserRepositoryIntegrationTest {
@Test
fun `should find user by id`() {
// given
val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
val user = User(UUID.randomUUID(), "John Doe", "john@example.com")
userRepository.save(user)
// when
@@ -108,9 +108,9 @@ class UserRepositoryIntegrationTest {
@Test
fun `should find all users`() {
// given
val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com", null)
val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com", null)
val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com")
val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com")
val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com")
userRepository.save(user1)
userRepository.save(user2)
@@ -138,7 +138,7 @@ class UserRepositoryIntegrationTest {
@Test
fun `should delete user by id`() {
// given
val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
val user = User(UUID.randomUUID(), "John Doe", "john@example.com")
userRepository.save(user)
// when
@@ -157,4 +157,41 @@ class UserRepositoryIntegrationTest {
// when & then (no exception should be thrown)
userRepository.deleteById(nonExistentId)
}
@Test
fun `should create OAuth user with provider identity`() {
val user = User(UUID.randomUUID(), "OAuth User", "oauth@example.com")
val savedUser = userRepository.createOAuthUser(user, AuthProvider.GOOGLE, "google-123")
assertEquals(user.id, savedUser.id)
assertEquals(user.email, savedUser.email)
val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123")
assertNotNull(foundByProvider)
assertEquals(user.id, foundByProvider?.id)
}
@Test
fun `should link OAuth account to existing user`() {
val user = userRepository.save(User(UUID.randomUUID(), "Link User", "link@example.com"))
val linkedUser = userRepository.linkOAuthAccount(user.id, AuthProvider.YANDEX, "yandex-456")
assertEquals(user.id, linkedUser.id)
val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.YANDEX, "yandex-456")
assertNotNull(foundByProvider)
assertEquals(user.id, foundByProvider?.id)
}
@Test
fun `find by email should include jellyfin user id`() {
val user = userRepository.save(User(UUID.randomUUID(), "Jellyfin User", "jellyfin@example.com"))
jdbcTemplate.update("UPDATE users SET jellyfin_user_id = ? WHERE id = ?", "jellyfin-789", user.id)
val foundUser = userRepository.findByEmail(user.email)
assertNotNull(foundUser)
assertEquals("jellyfin-789", foundUser?.jellyfinUserId)
}
}
@@ -1,11 +1,6 @@
package com.project.movienight.adapters.web
import com.project.movienight.application.ports.input.CreateFilmUseCase
import com.project.movienight.application.ports.input.DeleteFilmUseCase
import com.project.movienight.application.ports.input.EditFilmUseCase
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
import com.project.movienight.application.ports.input.FilmUseCase
import com.project.movienight.domain.model.Film
import io.mockk.every
import io.mockk.mockk
@@ -19,20 +14,15 @@ import java.util.UUID
class FilmControllerSearchTest {
private lateinit var mockMvc: MockMvc
private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase
private lateinit var filmUseCase: FilmUseCase
@BeforeEach
fun setup() {
searchFilmByTitleUseCase = mockk()
filmUseCase = mockk()
val controller =
FilmController(
createFilmUseCase = mockk<CreateFilmUseCase>(),
editFilmUseCase = mockk<EditFilmUseCase>(),
deleteFilmUseCase = mockk<DeleteFilmUseCase>(),
getFilmByIdUseCase = mockk<GetFilmByIdUseCase>(),
getAllFilmsUseCase = mockk<GetAllFilmsUseCase>(),
searchFilmByTitleUseCase = searchFilmByTitleUseCase,
filmUseCase = filmUseCase,
)
mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
@@ -43,7 +33,7 @@ class FilmControllerSearchTest {
val title = "Inception"
val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist")
every { searchFilmByTitleUseCase.searchByTitle(title) } returns film
every { filmUseCase.searchByTitle(title) } returns film
mockMvc
.get("/api/films/search") {
@@ -55,14 +45,14 @@ class FilmControllerSearchTest {
jsonPath("$.description") { value("A dream heist") }
}
verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) }
verify(exactly = 1) { filmUseCase.searchByTitle(title) }
}
@Test
fun `search returns 404 when title is not found`() {
val title = "Unknown Title"
every { searchFilmByTitleUseCase.searchByTitle(title) } returns null
every { filmUseCase.searchByTitle(title) } returns null
mockMvc
.get("/api/films/search") {
@@ -72,6 +62,6 @@ class FilmControllerSearchTest {
content { string("") }
}
verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) }
verify(exactly = 1) { filmUseCase.searchByTitle(title) }
}
}
@@ -1,15 +1,15 @@
package com.project.movienight.application.services
import com.project.movienight.adapters.metrics.BusinessMetricsService
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.Film
import com.project.movienight.domain.model.FilmLibraryEntry
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
@@ -22,83 +22,52 @@ import org.junit.jupiter.api.assertThrows
import java.util.UUID
class FilmLibraryServiceTest {
private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort
private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort
private lateinit var filmRepository: FilmRepositoryPort
private lateinit var idGenerator: IdGenerator
private lateinit var businessMetricsService: BusinessMetricsService
private lateinit var businessMetricsService: BusinessMetricsPort
private lateinit var filmLibraryService: FilmLibraryService
@BeforeEach
fun setup() {
filmLibraryRepository = mockk()
filmLibraryEntryRepository = mockk()
filmRepository = mockk()
idGenerator = mockk()
businessMetricsService = mockk(relaxed = true)
filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService)
}
@Test
fun `should throw EntityNotFoundException when creating library for user with no entries`() {
val userId = UUID.randomUUID()
val command = CreateFilmLibraryCommand(userId = userId, name = "My Films")
every { filmLibraryRepository.findAll() } returns emptyList()
assertThrows<EntityNotFoundException> {
filmLibraryService.create(command)
}
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { idGenerator.generateId() }
verify(exactly = 0) { filmLibraryRepository.save(any()) }
}
@Test
fun `should return existing library when user already has one`() {
val userId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = UUID.randomUUID(),
userId = userId,
filmId = UUID.randomUUID(),
comment = "Existing Library",
isViewed = false,
filmLibraryService =
FilmLibraryService(
filmLibraryEntryRepository,
filmRepository,
idGenerator,
businessMetricsService,
)
val command = CreateFilmLibraryCommand(userId = userId, name = "New Library")
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
val result = filmLibraryService.create(command)
assertEquals(existingLibrary, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { idGenerator.generateId() }
verify(exactly = 0) { filmLibraryRepository.save(any()) }
}
@Test
fun `should add film to new library when user has no library`() {
fun `should add film as new library entry`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val libraryId = UUID.randomUUID()
val entryId = UUID.randomUUID()
val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId)
val expectedLibrary =
FilmLibrary(
id = libraryId,
val expectedEntry =
FilmLibraryEntry(
id = entryId,
userId = userId,
filmId = filmId,
comment = null,
isViewed = false,
)
every { filmLibraryRepository.findAll() } returns emptyList()
every { idGenerator.generateId() } returns libraryId
every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description")
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null
every { idGenerator.generateId() } returns entryId
every {
filmLibraryRepository.save(
filmLibraryEntryRepository.save(
match {
it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false
},
)
} returns expectedLibrary
} returns expectedEntry
val result = filmLibraryService.addFilm(command)
@@ -106,62 +75,46 @@ class FilmLibraryServiceTest {
assertEquals(filmId, result.filmId)
assertEquals(userId, result.userId)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 1) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryRepository.save(any()) }
verify(exactly = 1) { filmLibraryEntryRepository.save(any()) }
}
@Test
fun `should add film as a new library entry when another film already exists`() {
fun `should reset viewed state when adding existing entry`() {
val userId = UUID.randomUUID()
val oldFilmId = UUID.randomUUID()
val newFilmId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
val filmId = UUID.randomUUID()
val existingEntry =
FilmLibraryEntry(
id = UUID.randomUUID(),
userId = userId,
filmId = oldFilmId,
filmId = filmId,
comment = "My Library",
isViewed = true,
)
val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId)
val createdLibrary =
FilmLibrary(
id = UUID.randomUUID(),
userId = userId,
filmId = newFilmId,
comment = null,
isViewed = false,
)
val updatedEntry = existingEntry.copy(isViewed = false, watchedAt = null)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
every { idGenerator.generateId() } returns createdLibrary.id
every {
filmLibraryRepository.save(
match {
it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false
},
)
} returns createdLibrary
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry
every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description")
every { filmLibraryEntryRepository.save(updatedEntry) } returns updatedEntry
val result = filmLibraryService.addFilm(command)
val result = filmLibraryService.addFilm(AddFilmToLibraryCommand(userId = userId, filmId = filmId))
assertEquals(newFilmId, result.filmId)
assertEquals(false, result.isViewed)
assertEquals(updatedEntry, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryRepository.save(any()) }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 0) { idGenerator.generateId() }
verify(exactly = 1) { filmLibraryEntryRepository.save(updatedEntry) }
}
@Test
fun `should remove film from library successfully`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val libraryId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = libraryId,
val entryId = UUID.randomUUID()
val existingEntry =
FilmLibraryEntry(
id = entryId,
userId = userId,
filmId = filmId,
comment = "My Library",
@@ -169,122 +122,70 @@ class FilmLibraryServiceTest {
)
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
justRun { filmLibraryRepository.deleteById(libraryId) }
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry
justRun { filmLibraryEntryRepository.deleteById(entryId) }
val result = filmLibraryService.removeFilm(command)
assertEquals(existingLibrary, result)
assertEquals(existingEntry, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 1) { filmLibraryEntryRepository.deleteById(entryId) }
}
@Test
fun `should throw EntityNotFoundException when removing film from non-existent library`() {
fun `should throw EntityNotFoundException when removing non-existent entry`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
every { filmLibraryRepository.findAll() } returns emptyList()
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null
assertThrows<EntityNotFoundException> {
filmLibraryService.removeFilm(command)
}
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) }
}
@Test
fun `should throw DomainException when removing film that is not in library`() {
val userId = UUID.randomUUID()
val libraryFilmId = UUID.randomUUID()
val differentFilmId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = UUID.randomUUID(),
userId = userId,
filmId = libraryFilmId,
comment = "My Library",
isViewed = false,
)
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
assertThrows<DomainException> {
filmLibraryService.removeFilm(command)
}
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
}
@Test
fun `should throw EntityNotFoundException when libraryId does not match`() {
fun `should throw DomainException when entry id belongs to another film`() {
val userId = UUID.randomUUID()
val filmId = UUID.randomUUID()
val actualLibraryId = UUID.randomUUID()
val wrongLibraryId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = actualLibraryId,
userId = userId,
filmId = filmId,
comment = "My Library",
isViewed = false,
)
val command =
RemoveFilmFromLibraryCommand(
userId = userId,
filmId = filmId,
libraryId = wrongLibraryId,
)
every { filmLibraryRepository.findById(wrongLibraryId) } returns null
assertThrows<EntityNotFoundException> {
filmLibraryService.removeFilm(command)
}
verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) }
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
}
@Test
fun `should get library successfully`() {
val userId = UUID.randomUUID()
val existingLibrary =
FilmLibrary(
id = UUID.randomUUID(),
val entryId = UUID.randomUUID()
val existingEntry =
FilmLibraryEntry(
id = entryId,
userId = userId,
filmId = UUID.randomUUID(),
comment = "My Library",
isViewed = false,
)
val query = GetFilmLibraryQuery(userId = userId)
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId, entryId = entryId)
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
every { filmLibraryEntryRepository.findById(entryId) } returns existingEntry
val result = filmLibraryService.getLibrary(query)
assertThrows<DomainException> {
filmLibraryService.removeFilm(command)
}
assertEquals(existingLibrary, result)
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryEntryRepository.findById(entryId) }
verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) }
}
@Test
fun `should throw EntityNotFoundException when getting non-existent library`() {
fun `should list entries by user`() {
val userId = UUID.randomUUID()
val query = GetFilmLibraryQuery(userId = userId)
val entries =
listOf(
FilmLibraryEntry(UUID.randomUUID(), userId, UUID.randomUUID(), null, false),
)
every { filmLibraryRepository.findAll() } returns emptyList()
every { filmLibraryEntryRepository.findByUserId(userId) } returns entries
assertThrows<EntityNotFoundException> {
filmLibraryService.getLibrary(query)
}
assertEquals(entries, filmLibraryService.list(userId))
verify(exactly = 1) { filmLibraryRepository.findAll() }
verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) }
}
}
@@ -2,13 +2,13 @@ package com.project.movienight.application.services
import com.project.movienight.application.ports.input.CreateFilmCommand
import com.project.movienight.application.ports.input.EditFilmCommand
import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.config.FilmServiceProperties
import com.project.movienight.domain.exception.BlockedValueException
import com.project.movienight.domain.exception.EntityNotFoundException
import com.project.movienight.domain.model.Film
import io.micrometer.core.instrument.simple.SimpleMeterRegistry
import io.mockk.every
import io.mockk.justRun
import io.mockk.mockk
@@ -24,7 +24,7 @@ class FilmServiceTest {
private lateinit var filmRepository: FilmRepositoryPort
private lateinit var idGenerator: IdGenerator
private lateinit var filmConfig: FilmServiceProperties
private lateinit var meterRegistry: SimpleMeterRegistry
private lateinit var businessMetricsService: BusinessMetricsPort
private lateinit var filmService: FilmService
@BeforeEach
@@ -32,8 +32,8 @@ class FilmServiceTest {
filmRepository = mockk()
idGenerator = mockk()
filmConfig = mockk()
meterRegistry = SimpleMeterRegistry()
filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry)
businessMetricsService = mockk(relaxed = true)
filmService = FilmService(filmRepository, idGenerator, filmConfig, businessMetricsService)
}
@Test
@@ -37,7 +37,7 @@ class UserServiceTest {
fun `should create user successfully`() {
val command = CreateUserCommand(name = "John Doe", email = "john@example.com")
val userId = UUID.randomUUID()
val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com")
every { userConfig.isBlocked("John Doe") } returns false
every { idGenerator.generateId() } returns userId
@@ -74,8 +74,8 @@ class UserServiceTest {
fun `should edit user successfully`() {
val userId = UUID.randomUUID()
val command = EditUserCommand(name = "Jane Doe")
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com", library = null)
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com")
val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com")
every { userConfig.isBlocked("Jane Doe") } returns false
every { userRepository.findById(userId) } returns existingUser
@@ -128,7 +128,7 @@ class UserServiceTest {
@Test
fun `should delete user successfully`() {
val userId = UUID.randomUUID()
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com")
every { userRepository.findById(userId) } returns existingUser
justRun { userRepository.deleteById(userId) }
@@ -0,0 +1,29 @@
package com.project.movienight.config
import org.springframework.boot.test.context.TestConfiguration
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Primary
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain
@TestConfiguration
@EnableWebSecurity
class TestSecurityConfiguration {
@Bean
@Primary
fun testSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.authorizeHttpRequests { auth ->
auth.anyRequest().permitAll()
}.csrf { csrf ->
csrf.disable()
}.headers { headers ->
headers.frameOptions { frameOptions ->
frameOptions.sameOrigin()
}
}
return http.build()
}
}