feat: Jellyfin Plugin UI Integration #51

Merged
devitq merged 13 commits from feature/jellyfin-plugin-ui-integration-2323592149917875874 into feat/implement-jellyfin-plugin-46 2026-05-22 13:36:25 +00:00
94 changed files with 4939 additions and 158 deletions
Showing only changes of commit e56c3be14b - Show all commits
1
@@ -13,66 +13,335 @@
const showMsg = getAlert();
function injectUI() {
const headerButtons = document.querySelector('.headerViewButtons, .view-library .content-primary, .home-section .sectionTitleContainer');
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;
}
if (headerButtons) {
if (!document.querySelector('.btnMovieNightRecommend')) {
const btn = document.createElement('button');
btn.className = 'emby-button raised btnMovieNightRecommend';
btn.innerHTML = '<span>Recommend Film</span>';
btn.style.marginLeft = '1em';
btn.onclick = showRecommendation;
headerButtons.appendChild(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;
}
if (!document.querySelector('.btnMovieNightAddMovie')) {
const btn = document.createElement('button');
btn.className = 'emby-button raised btnMovieNightAddMovie';
btn.innerHTML = '<span>Add Movie (STRM)</span>';
btn.style.marginLeft = '1em';
btn.onclick = promptAddMovie;
headerButtons.appendChild(btn);
async function injectUI() {
// Check for onboarding
await checkOnboarding();
// 1. Item Detail Page
const detailButtons = document.querySelector('.mainDetailButtons');
if (detailButtons) {
const itemId = getItemIdFromUrl();
if (itemId) {
// MovieNight Rating
if (!document.querySelector('.btnMovieNightRate')) {
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
});
insertInDetailRow(detailButtons, rateBtn);
}
// Mark Viewed in MovieNight
if (!document.querySelector('.btnMovieNightMarkViewed')) {
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
});
insertInDetailRow(detailButtons, viewedBtn);
}
}
}
const detailButtons = document.querySelector('.itemDetailButtons, .itemDetailsButtons');
if (detailButtons && !document.querySelector('.movieNightRatingContainer')) {
const itemId = getItemIdFromUrl();
if (itemId) {
const container = document.createElement('div');
container.className = 'movieNightRatingContainer';
container.style.display = 'inline-flex';
container.style.alignItems = 'center';
container.style.marginLeft = '1em';
// 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();
}));
}
const label = document.createElement('span');
label.innerText = 'MovieNight: ';
label.style.marginRight = '0.5em';
container.appendChild(label);
// 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));
const select = document.createElement('select');
select.className = 'emby-select';
select.style.padding = '0.2em';
for (let i = 0; i <= 10; i++) {
const opt = document.createElement('option');
opt.value = i;
opt.innerText = i === 0 ? 'Rate...' : i;
select.appendChild(opt);
}
select.onchange = (e) => submitRating(itemId, e.target.value);
container.appendChild(select);
detailButtons.appendChild(container);
}
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 params = new URLSearchParams(window.location.search);
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');
}
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:37 +00:00 (Migrated from github.com)
Review

itemId is taken directly from the page URL and then used as {filmId} in the rating POST path. Elsewhere in the plugin (sync/playback events) Jellyfin ids are serialized using ToString("N") (no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URL id to the same canonical format before calling the API (and applying encodeURIComponent when interpolating path segments).

`itemId` is taken directly from the page URL and then used as `{filmId}` in the rating POST path. Elsewhere in the plugin (sync/playback events) Jellyfin ids are serialized using `ToString("N")` (no dashes), so this can lead to inconsistent identifiers being sent to the backend depending on URL format. Consider normalizing the URL `id` to the same canonical format before calling the API (and applying `encodeURIComponent` when interpolating path segments).
function createOverlay() {
const overlay = document.createElement('div');
overlay.className = 'dialogBackdrop dialogBackdropOpened';
overlay.style.zIndex = '99998';
overlay.style.backgroundColor = 'rgba(0,0,0,0.7)';
overlay.style.position = 'fixed';
overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0';
overlay.style.backdropFilter = 'blur(8px)';
overlay.style.opacity = '1';
return overlay;
}
function createDialogBase(title) {
const dialog = document.createElement('div');
dialog.className = 'dialog';
dialog.style.position = 'fixed';
dialog.style.top = '50%'; dialog.style.left = '50%';
dialog.style.transform = 'translate(-50%, -50%)';
dialog.style.zIndex = '99999';
dialog.style.padding = '2.5em';
dialog.style.minWidth = '350px';
dialog.style.backgroundColor = '#1a1a1a';
dialog.style.borderRadius = '1.5em';
dialog.style.color = 'white';
dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)';
dialog.style.border = '1px solid #444';
dialog.style.opacity = '1';
dialog.innerHTML = `
<h2 style="margin-top:0; text-align:center; font-weight:400; color:white; opacity:1;">${title}</h2>
<div class="dialog-content" style="margin:1.5em 0; opacity:1;"></div>
<div class="dialog-footer" style="display:flex; gap:1em; opacity:1;">
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white !important; opacity:1;">Cancel</button>
</div>
`;
return dialog;
}
async function showRatingDialog(itemId) {
const overlay = createOverlay();
const dialog = createDialogBase('Rate on MovieNight');
const content = dialog.querySelector('.dialog-content');
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.8em;"></div>`;
const grid = content.querySelector('.rating-grid');
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
for (let i = 1; i <= 10; i++) {
const btn = document.createElement('button');
btn.type = 'button'; btn.is = 'emby-button';
btn.className = 'emby-button raised';
btn.innerText = i;
btn.style.padding = '0.8em 0';
btn.style.textAlign = 'center';
btn.style.display = 'flex';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'center';
btn.style.fontSize = '1.2em';
btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
grid.appendChild(btn);
}
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
async function showAddMovieDialog() {
const overlay = createOverlay();
const dialog = createDialogBase('Add Movie (STRM)');
const content = dialog.querySelector('.dialog-content');
const footer = dialog.querySelector('.dialog-footer');
content.innerHTML = `
<div style="margin-bottom:1em;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Movie Title (Required)</label>
<input type="text" class="emby-input txtTitle" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="e.g. Inception">
</div>
<div style="display:flex; gap:1em; margin-bottom:1em;">
<div style="flex:1;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Year</label>
<input type="number" class="emby-input txtYear" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="2010">
</div>
<div style="flex:2;">
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">IMDb ID</label>
<input type="text" class="emby-input txtImdb" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="tt1375666">
</div>
</div>
<div>
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Stream URL (Optional)</label>
<input type="text" class="emby-input txtUrl" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="http://...">
</div>
`;
const btnAdd = document.createElement('button');
btnAdd.className = 'emby-button raised button-submit';
btnAdd.style.flex = '2';
btnAdd.style.backgroundColor = '#0064d2';
btnAdd.innerHTML = '<span>Add Film</span>';
footer.insertBefore(btnAdd, footer.firstChild);
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
btnAdd.onclick = async () => {
const title = dialog.querySelector('.txtTitle').value;
const year = dialog.querySelector('.txtYear').value;
const imdbId = dialog.querySelector('.txtImdb').value;
const url = dialog.querySelector('.txtUrl').value;
if (!title) return;
cleanup();
await addMovie(title, url, year, imdbId);
};
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
dialog.querySelector('.txtTitle').focus();
}
async function showOnboardingDialog() {
const overlay = createOverlay();
const dialog = createDialogBase('Welcome to MovieNight!');
dialog.style.minWidth = '450px';
const content = dialog.querySelector('.dialog-content');
const footer = dialog.querySelector('.dialog-footer');
content.innerHTML = `
<p style="margin-bottom:1.5em; opacity:0.8; text-align:center;">Pick your preferences to get better recommendations.</p>
<div style="margin-bottom:1.5em;">
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Favorite Genres</label>
<div class="genre-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
<div style="margin-bottom:1.5em;">
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Preferred Eras</label>
<div class="era-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
<div>
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Content Types</label>
<div class="type-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
</div>
`;
const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"];
const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"];
const types = ["FILM", "SERIES"];
const selections = { genres: new Set(), eras: new Set(), types: new Set() };
const createChip = (text, container, type) => {
const chip = document.createElement('div');
chip.innerText = text;
chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;';
chip.onclick = () => {
if (selections[type].has(text)) {
selections[type].delete(text);
chip.style.backgroundColor = 'transparent';
chip.style.borderColor = '#444';
} else {
selections[type].add(text);
chip.style.backgroundColor = '#0064d2';
chip.style.borderColor = '#0064d2';
}
};
container.appendChild(chip);
};
genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres'));
eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras'));
types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types'));
const btnSave = document.createElement('button');
btnSave.className = 'emby-button raised button-submit';
btnSave.style.flex = '2';
btnSave.style.backgroundColor = '#0064d2';
btnSave.innerHTML = '<span>Save & Start</span>';
footer.insertBefore(btnSave, footer.firstChild);
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
btnSave.onclick = async () => {
const payload = {
weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])),
eras: [...selections.eras],
contentTypes: [...selections.types]
};
cleanup();
await completeOnboarding(payload);
};
dialog.querySelector('.btnCancel').onclick = cleanup;
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
overlay.appendChild(dialog);
document.body.appendChild(overlay);
}
async function checkOnboarding() {
if (window.movieNightOnboardingChecked) return;
window.movieNightOnboardingChecked = true;
const userId = ApiClient.getCurrentUserId();
if (!userId) return;
try {
const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`));
if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) {
showOnboardingDialog();
}
} catch (err) {
if (err.status === 404) showOnboardingDialog();
}
}
async function completeOnboarding(payload) {
const userId = ApiClient.getCurrentUserId();
try {
await ApiClient.ajax({
type: 'POST',
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`),
data: JSON.stringify(payload),
contentType: 'application/json'
});
showMsg('Welcome! Your preferences have been saved.');
} catch (err) {
showMsg('Failed to save onboarding preferences.');
}
}
async function showRecommendation() {
const userId = ApiClient.getCurrentUserId();
try {
@@ -91,30 +360,51 @@
}
} catch (err) {
console.error('Failed to get recommendations', err);
showMsg('Failed to get recommendations from MovieNight.');
showMsg('Failed to get recommendations. Check your API token and MovieNight status.');
}
}
async function promptAddMovie() {
const title = prompt("Enter movie title:");
if (!title) return;
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({ title: title }),
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. Check plugin configuration and logs.');
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) {
if (score === "0") return;
const userId = ApiClient.getCurrentUserId();
try {
await ApiClient.ajax({
@@ -123,14 +413,37 @@
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
contentType: 'application/json'
});
showMsg('Rating submitted!');
showMsg('Rating submitted to MovieNight!');
} catch (err) {
console.error('Failed to submit rating', err);
showMsg('Failed to submit rating to MovieNight.');
showMsg('Failed to submit rating.');
}
}
const observer = new MutationObserver(injectUI);
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();
@@ -1,8 +1,11 @@
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;
@@ -12,7 +15,6 @@ namespace Jellyfin.Plugin.MovieNight.Controllers;
/// Admin endpoints for the MovieNight plugin.
/// </summary>
[ApiController]
[Authorize]
[Route("MovieNight")]
public class MovieNightController : ControllerBase
{
@@ -22,17 +24,26 @@ public class MovieNightController : ControllerBase
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
/// </summary>
public MovieNightController(MovieNightBackendClient backendClient, MovieNightSyncService syncService)
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;
@@ -50,6 +61,7 @@ public class MovieNightController : ControllerBase
/// <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);
@@ -61,6 +73,7 @@ public class MovieNightController : ControllerBase
/// <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);
@@ -73,6 +86,7 @@ public class MovieNightController : ControllerBase
/// <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);
@@ -82,6 +96,7 @@ public class MovieNightController : ControllerBase
/// Gets recommendations for the current user.
/// </summary>
[HttpGet("Users/{userId}/Recommendations")]
[Authorize]
public async Task<ActionResult<string>> GetRecommendations(
[FromRoute] string userId,
[FromQuery] string? contentType,
@@ -96,6 +111,7 @@ public class MovieNightController : ControllerBase
/// 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,
@@ -110,6 +126,7 @@ public class MovieNightController : ControllerBase
/// 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,
@@ -121,9 +138,37 @@ public class MovieNightController : ControllerBase
}
/// <summary>
/// Creates a new film by generating a .strm file.
/// Gets user preferences.
/// </summary>
[HttpGet("Users/{userId}/Preferences")]
[Authorize]
public async Task<ActionResult<string?>> GetPreferences(
[FromRoute] string userId,
CancellationToken cancellationToken)
{
return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
[HttpPost("Users/{userId}/Onboarding")]
[Authorize]
public async Task<ActionResult> CompleteOnboarding(
[FromRoute] string userId,
[FromBody] object payload,
CancellationToken cancellationToken)
{
await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
return Ok();
}
/// <summary>
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
/// </summary>
[HttpPost("Films")]
copilot-pull-request-reviewer[bot] commented 2026-05-20 17:42:37 +00:00 (Migrated from github.com)
Review

These endpoints are documented as operating on the “current user”, but they accept an arbitrary userId in the route and do not validate it against the authenticated Jellyfin user. Any authenticated user could request recommendations/submit ratings/mark viewed on behalf of another user by changing the URL. Prefer deriving the user id from the auth context (and removing the route param) or explicitly rejecting requests where userId doesn’t match the authenticated principal.

These endpoints are documented as operating on the “current user”, but they accept an arbitrary `userId` in the route and do not validate it against the authenticated Jellyfin user. Any authenticated user could request recommendations/submit ratings/mark viewed on behalf of another user by changing the URL. Prefer deriving the user id from the auth context (and removing the route param) or explicitly rejecting requests where `userId` doesn’t match the authenticated principal.
[Authorize]
public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request)
{
var config = Plugin.Instance?.Configuration;
@@ -132,22 +177,45 @@ public class MovieNightController : ControllerBase
return BadRequest("STRM output path is not configured.");
}
if (string.IsNullOrWhiteSpace(request.Title))
{
return BadRequest("Movie title is required.");
}
try
{
if (!Directory.Exists(config.StrmOutputPath))
// Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]"
var folderName = request.Title.Trim();
if (request.Year.HasValue)
{
Directory.CreateDirectory(config.StrmOutputPath);
folderName += $" ({request.Year})";
}
if (!string.IsNullOrWhiteSpace(request.ImdbId))
{
var ttId = request.ImdbId.Trim().ToLowerInvariant();
if (!ttId.StartsWith("tt")) ttId = "tt" + ttId;
folderName += $" [imdbid-{ttId}]";
}
var safeTitle = string.Join("_", request.Title.Split(Path.GetInvalidFileNameChars()));
var fileName = $"{safeTitle}.strm";
var filePath = Path.Combine(config.StrmOutputPath, fileName);
// Sanitize for file system
var invalidChars = Path.GetInvalidFileNameChars();
var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray());
// Placeholder content for the .strm file.
// In a real scenario, this could be a URL provided in the request.
await System.IO.File.WriteAllTextAsync(filePath, "http://placeholder.url/upload_me_later").ConfigureAwait(false);
var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName);
if (!Directory.Exists(movieDirectory))
{
Directory.CreateDirectory(movieDirectory);
}
return Ok(new { FilePath = filePath });
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)
{
@@ -159,7 +227,7 @@ public class MovieNightController : ControllerBase
/// <summary>
/// Create film request.
/// </summary>
public sealed record CreateFilmRequest(string Title);
public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
/// <summary>
/// Rating request.
@@ -167,6 +167,34 @@ public class MovieNightBackendClient
return body;
}
/// <summary>
/// Gets user preferences.
/// </summary>
public async Task<string?> GetPreferencesAsync(string userId, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences");
if (request is null) return null;
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return body;
}
/// <summary>
/// Completes onboarding for a user.
/// </summary>
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding");
if (request is null) return;
request.Content = JsonContent.Create(payload, options: JsonOptions);
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
}
/// <summary>
/// Pushes an event payload to the backend event endpoint.
/// </summary>
@@ -3,8 +3,10 @@ package com.project.movienight
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
@ConfigurationPropertiesScan("com.project.movienight.config")
class MovieNightApplication
@@ -0,0 +1,143 @@
package com.project.movienight.adapters.jellyfin
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.ObjectMapper
import com.project.movienight.config.JellyfinIntegrationProperties
import com.project.movienight.domain.model.ContentType
import org.springframework.stereotype.Service
import java.net.URI
import java.net.http.HttpClient
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,
) {
private val httpClient: HttpClient =
HttpClient
.newBuilder()
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
.build()
fun fetchUsers(): List<JellyfinRemoteUser> =
request("Users")
.asItems()
.mapNotNull { node ->
val id = node.fieldText("Id") ?: return@mapNotNull null
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
}
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",
).asItems().mapNotNull { node ->
val itemId = node.fieldText("Id") ?: return@mapNotNull null
val providerIds = node["ProviderIds"]
val imdbId = providerIds?.fieldText("Imdb")
val people = node["People"]
val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList()
val directors = people?.peopleByType("Director") ?: emptyList()
JellyfinLibraryItemSnapshot(
jellyfinItemId = itemId,
title = node.fieldText("Name") ?: itemId,
description = node.fieldText("Overview") ?: "",
contentType = mapContentType(node.fieldText("Type")),
releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(),
genres = node["Genres"]?.textList() ?: emptyList(),
cast = cast,
directors = directors,
platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(),
imdbRating = null,
externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" },
jellyfinLibraryId = node.fieldText("ParentId"),
isPlayed =
node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false,
)
}
private fun request(path: String): JsonNode {
val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path")
val request =
HttpRequest
.newBuilder(uri)
.timeout(Duration.ofMillis(properties.requestTimeoutMs))
.header("Accept", "application/json")
.header("X-Emby-Token", properties.apiKey)
.GET()
.build()
val response =
try {
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
} catch (
@Suppress("TooGenericExceptionCaught") exception: Exception,
) {
throw IllegalStateException("Failed to call Jellyfin at $uri", exception)
}
check(response.statusCode() in 200..299) {
"Jellyfin request failed with status ${response.statusCode()} for $uri"
}
return objectMapper.readTree(response.body())
}
private fun JsonNode.asItems(): List<JsonNode> =
when {
isArray -> map { it }
has("Items") && this["Items"].isArray -> this["Items"].map { it }
else -> emptyList()
}
private fun JsonNode.fieldText(name: String): String? =
get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() }
private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean()
private fun JsonNode.textList(): List<String> =
takeIf { it.isArray }?.mapNotNull { item ->
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
}
?: emptyList()
private fun JsonNode.peopleByType(vararg types: String): List<String> {
if (!isArray) return emptyList()
return mapNotNull { person ->
val type = person.fieldText("Type") ?: return@mapNotNull null
if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null
}
}
private fun mapContentType(value: String?): ContentType =
when (value?.lowercase()) {
"movie" -> ContentType.FILM
"series" -> ContentType.SERIES
"episode" -> ContentType.EPISODE
else -> ContentType.OTHER
}
}
@@ -0,0 +1,75 @@
package com.project.movienight.adapters.metrics
import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.RecommendationEventType
import io.micrometer.core.instrument.Counter
import io.micrometer.core.instrument.MeterRegistry
import io.micrometer.core.instrument.Timer
import org.springframework.stereotype.Service
import java.util.concurrent.atomic.AtomicInteger
@Service
class BusinessMetricsService(
private val meterRegistry: MeterRegistry,
) {
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_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")
private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total")
private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total")
private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total")
private val jellyfinSyncDuration: Timer =
Timer
.builder("business_jellyfin_sync_duration_seconds")
.publishPercentileHistogram()
.register(meterRegistry)
private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total")
private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0)
init {
meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue)
}
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
fun recordRecommendationRequest() {
recommendationRequests.increment()
}
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
Counter
.builder("recommendation_weights_updated_total")
.tag("eventType", eventType.name)
.register(meterRegistry)
.increment()
}
fun recordRatingSubmitted() {
ratingsSubmitted.increment()
}
fun recordLibraryEvent() {
libraryEvents.increment()
}
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
jellyfinSyncRuns.increment()
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
jellyfinSyncedItems.increment(summary.syncedItems.toDouble())
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
}
fun recordJellyfinSyncFailure() {
jellyfinSyncFailures.increment()
}
fun recordJellyfinUnmappedUser() {
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
}
fun recordBackendWriteFailure() {
backendWriteFailures.increment()
}
}
@@ -0,0 +1,37 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.domain.model.FilmRating
import java.time.LocalDateTime
import java.util.UUID
data class FilmRatingEntity(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val score: Int,
val note: String?,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
)
fun FilmRatingEntity.toDomain(): FilmRating =
FilmRating(
id = id,
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = createdAt,
updatedAt = updatedAt,
)
fun FilmRating.toEntity(): FilmRatingEntity =
FilmRatingEntity(
id = id,
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = createdAt,
updatedAt = updatedAt,
)
@@ -0,0 +1,31 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.domain.model.JellyfinSyncState
import java.time.LocalDateTime
import java.util.UUID
data class JellyfinSyncStateEntity(
val userId: UUID,
val lastSyncedAt: LocalDateTime?,
val lastSuccessfulSyncAt: LocalDateTime?,
val lastError: String?,
val syncedItemCount: Int,
)
fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState =
JellyfinSyncState(
userId = userId,
lastSyncedAt = lastSyncedAt,
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
lastError = lastError,
syncedItemCount = syncedItemCount,
)
fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity =
JellyfinSyncStateEntity(
userId = userId,
lastSyncedAt = lastSyncedAt,
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
lastError = lastError,
syncedItemCount = syncedItemCount,
)
@@ -11,6 +11,7 @@ data class UserEntity(
val email: String,
val provider: String?,
val providerId: String?,
val jellyfinUserId: String?,
val createdAt: LocalDateTime,
)
@@ -20,6 +21,8 @@ fun UserEntity.toDomain(): User =
name = name,
email = email,
library = null,
preferences = null,
jellyfinUserId = jellyfinUserId,
)
fun User.toEntity(
@@ -33,5 +36,6 @@ fun User.toEntity(
email = email,
provider = provider?.name,
providerId = providerId,
jellyfinUserId = jellyfinUserId,
createdAt = createdAt,
)
@@ -0,0 +1,41 @@
package com.project.movienight.adapters.persistence.entity
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
data class UserPreferencesEntity(
val userId: UUID,
val weightedGenres: String,
val plotTypes: String,
val eras: String,
val castAndDirectors: String,
val moods: String,
val contentTypes: String,
)
fun UserPreferencesEntity.toDomain(): UserPreferences =
UserPreferences(
userId = userId,
weightedGenres = DelimitedValueCodec.decodeWeightedMap(weightedGenres),
plotTypes = DelimitedValueCodec.decodeList(plotTypes),
eras = DelimitedValueCodec.decodeList(eras),
castAndDirectors = DelimitedValueCodec.decodeList(castAndDirectors),
moods = DelimitedValueCodec.decodeList(moods),
contentTypes =
DelimitedValueCodec.decodeList(contentTypes).mapNotNull { value ->
runCatching { ContentType.valueOf(value) }.getOrNull()
},
)
fun UserPreferences.toEntity(): UserPreferencesEntity =
UserPreferencesEntity(
userId = userId,
weightedGenres = DelimitedValueCodec.encodeWeightedMap(weightedGenres),
plotTypes = DelimitedValueCodec.encodeList(plotTypes),
eras = DelimitedValueCodec.encodeList(eras),
castAndDirectors = DelimitedValueCodec.encodeList(castAndDirectors),
moods = DelimitedValueCodec.encodeList(moods),
contentTypes = DelimitedValueCodec.encodeList(contentTypes.map { it.name }),
)
@@ -18,6 +18,7 @@ class FilmLibraryRepository(
filmId = UUID.fromString(rs.getString("film_id")),
comment = rs.getString("comment"),
isViewed = rs.getBoolean("is_viewed"),
watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(),
)
}
@@ -26,26 +27,28 @@ class FilmLibraryRepository(
jdbc.update(
"""
UPDATE favorites
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?
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,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed)
VALUES (?, ?, ?, ?, ?)
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,
)
}
return filmLibrary
@@ -54,16 +57,33 @@ class FilmLibraryRepository(
override fun findById(id: UUID): FilmLibrary? {
val entries =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?",
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
filmLibraryRowMapper,
id,
)
return entries.firstOrNull()
}
override fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary? {
val entries =
jdbc.query(
"""
SELECT id, user_id, film_id, comment, is_viewed, watched_at
FROM favorites WHERE user_id = ? AND film_id = ?
""".trimIndent(),
filmLibraryRowMapper,
userId,
filmId,
)
return entries.firstOrNull()
}
override fun findAll(): List<FilmLibrary> =
jdbc.query(
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites",
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
filmLibraryRowMapper,
)
@@ -0,0 +1,117 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.FilmRatingEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
import com.project.movienight.domain.model.FilmRating
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.LocalDateTime
import java.util.UUID
@Repository
class FilmRatingRepository(
private val jdbc: JdbcTemplate,
) : FilmRatingRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
FilmRatingEntity(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
score = rs.getInt("score"),
note = rs.getString("note"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
)
}
override fun save(rating: FilmRating): FilmRating {
val entity = rating.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE film_ratings
SET score = ?,
note = ?,
updated_at = ?
WHERE user_id = ?
AND film_id = ?
""".trimIndent(),
entity.score,
entity.note,
LocalDateTime.now(),
entity.userId,
entity.filmId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO film_ratings (
id,
user_id,
film_id,
score,
note,
created_at,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.id,
entity.userId,
entity.filmId,
entity.score,
entity.note,
entity.createdAt,
entity.updatedAt,
)
}
return rating
}
override fun findByUserId(userId: UUID): List<FilmRating> =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
score,
note,
created_at,
updated_at
FROM film_ratings
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).map { it.toDomain() }
override fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmRating? =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
score,
note,
created_at,
updated_at
FROM film_ratings
WHERE user_id = ?
AND film_id = ?
""".trimIndent(),
rowMapper,
userId,
filmId,
).firstOrNull()
?.toDomain()
}
@@ -1,6 +1,8 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
@@ -16,6 +18,21 @@ class FilmRepository(
id = UUID.fromString(rs.getString("id")),
title = rs.getString("title"),
description = rs.getString("description"),
contentType =
runCatching {
ContentType.valueOf(
rs.getString("content_type"),
)
}.getOrDefault(ContentType.FILM),
releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() },
genres = DelimitedValueCodec.decodeList(rs.getString("genres")),
cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")),
directors = DelimitedValueCodec.decodeList(rs.getString("directors")),
imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() },
platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() },
externalUrl = rs.getString("external_url"),
jellyfinItemId = rs.getString("jellyfin_item_id"),
jellyfinLibraryId = rs.getString("jellyfin_library_id"),
)
}
@@ -24,22 +41,67 @@ class FilmRepository(
jdbc.update(
"""
UPDATE films
SET title = ?, description = ?
SET title = ?,
description = ?,
content_type = ?,
release_year = ?,
genres = ?,
cast_members = ?,
directors = ?,
imdb_rating = ?,
platform_rating = ?,
external_url = ?,
jellyfin_item_id = ?,
jellyfin_library_id = ?
WHERE id = ?
""".trimIndent(),
film.title,
film.description,
film.contentType.name,
film.releaseYear,
DelimitedValueCodec.encodeList(film.genres),
DelimitedValueCodec.encodeList(film.cast),
DelimitedValueCodec.encodeList(film.directors),
film.imdbRating,
film.platformRating,
film.externalUrl,
film.jellyfinItemId,
film.jellyfinLibraryId,
film.id,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO films (id, title, description)
VALUES (?, ?, ?)
INSERT INTO films (
id,
title,
description,
content_type,
release_year,
genres,
cast_members,
directors,
imdb_rating,
platform_rating,
external_url,
jellyfin_item_id,
jellyfin_library_id
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
film.id,
film.title,
film.description,
film.contentType.name,
film.releaseYear,
DelimitedValueCodec.encodeList(film.genres),
DelimitedValueCodec.encodeList(film.cast),
DelimitedValueCodec.encodeList(film.directors),
film.imdbRating,
film.platformRating,
film.externalUrl,
film.jellyfinItemId,
film.jellyfinLibraryId,
)
}
return film
@@ -48,20 +110,137 @@ class FilmRepository(
override fun findById(id: UUID): Film? {
val films =
jdbc.query(
"SELECT id, title, description FROM films WHERE id = ?",
"""
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 id = ?
""".trimIndent(),
filmRowMapper,
id,
)
return films.firstOrNull()
}
override fun findByJellyfinItemId(jellyfinItemId: 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_item_id = ?
""".trimIndent(),
filmRowMapper,
jellyfinItemId,
)
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(
"SELECT id, title, description FROM films",
"""
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
""".trimIndent(),
filmRowMapper,
)
override fun findByTitle(title: 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 title = ?
ORDER BY id
LIMIT 1
""".trimIndent(),
filmRowMapper,
title,
)
return films.firstOrNull()
}
override fun deleteById(id: UUID) {
jdbc.update("DELETE FROM films WHERE id = ?", id)
jdbc.update(
"""
DELETE FROM films
WHERE id = ?
""".trimIndent(),
id,
)
}
}
@@ -0,0 +1,45 @@
package com.project.movienight.adapters.persistence.jdbc
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
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 {
val sql =
"""
INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload)
VALUES (:eventId, :serverId, :eventType, :occurredAt, :jellyfinUserId, :jellyfinItemId, cast(:payload as jsonb))
ON CONFLICT (event_id) DO NOTHING
""".trimIndent()
val params =
MapSqlParameterSource()
.addValue("eventId", eventId)
.addValue("serverId", serverId)
.addValue("eventType", eventType)
.addValue("occurredAt", occurredAt)
.addValue("jellyfinUserId", jellyfinUserId)
.addValue("jellyfinItemId", jellyfinItemId)
.addValue("payload", 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)
}
}
@@ -0,0 +1,100 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
import com.project.movienight.domain.model.JellyfinSyncState
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class JellyfinSyncStateRepository(
private val jdbc: JdbcTemplate,
) : JellyfinSyncStateRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
JellyfinSyncStateEntity(
userId = UUID.fromString(rs.getString("user_id")),
lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(),
lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(),
lastError = rs.getString("last_error"),
syncedItemCount = rs.getInt("synced_item_count"),
)
}
override fun save(state: JellyfinSyncState): JellyfinSyncState {
val entity = state.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE jellyfin_sync_state
SET last_synced_at = ?,
last_successful_sync_at = ?,
last_error = ?,
synced_item_count = ?,
updated_at = CURRENT_TIMESTAMP
WHERE user_id = ?
""".trimIndent(),
entity.lastSyncedAt,
entity.lastSuccessfulSyncAt,
entity.lastError,
entity.syncedItemCount,
entity.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO jellyfin_sync_state (
user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
)
VALUES (?, ?, ?, ?, ?)
""".trimIndent(),
entity.userId,
entity.lastSyncedAt,
entity.lastSuccessfulSyncAt,
entity.lastError,
entity.syncedItemCount,
)
}
return state
}
override fun findByUserId(userId: UUID): JellyfinSyncState? =
jdbc
.query(
"""
SELECT user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
FROM jellyfin_sync_state
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
?.toDomain()
override fun findAll(): List<JellyfinSyncState> =
jdbc
.query(
"""
SELECT user_id,
last_synced_at,
last_successful_sync_at,
last_error,
synced_item_count
FROM jellyfin_sync_state
""".trimIndent(),
rowMapper,
).map { it.toDomain() }
}
@@ -0,0 +1,116 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class RecommendationEventRepository(
private val jdbc: JdbcTemplate,
) : RecommendationEventRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
RecommendationEvent(
id = UUID.fromString(rs.getString("id")),
userId = UUID.fromString(rs.getString("user_id")),
filmId = UUID.fromString(rs.getString("film_id")),
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
score = rs.getObject("score")?.let { (it as Number).toDouble() },
relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() },
qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() },
contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() },
noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() },
diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() },
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
)
}
override fun save(event: RecommendationEvent): RecommendationEvent {
jdbc.update(
"""
INSERT INTO recommendation_events (
id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
event.id,
event.userId,
event.filmId,
event.eventType.name,
event.score,
event.relevanceScore,
event.qualityScore,
event.contextScore,
event.noveltyScore,
event.diversityScore,
event.createdAt,
)
return event
}
override fun findByUserId(userId: UUID): List<RecommendationEvent> =
jdbc.query(
"""
SELECT id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
FROM recommendation_events
WHERE user_id = ?
ORDER BY created_at DESC
""".trimIndent(),
rowMapper,
userId,
)
override fun findLatestRecommended(
userId: UUID,
filmId: UUID,
): RecommendationEvent? =
jdbc
.query(
"""
SELECT id,
user_id,
film_id,
event_type,
score,
relevance_score,
quality_score,
context_score,
novelty_score,
diversity_score,
created_at
FROM recommendation_events
WHERE user_id = ?
AND film_id = ?
AND event_type = ?
ORDER BY created_at DESC
LIMIT 1
""".trimIndent(),
rowMapper,
userId,
filmId,
RecommendationEventType.RECOMMENDED.name,
).firstOrNull()
}
@@ -0,0 +1,97 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.adapters.persistence.entity.UserPreferencesEntity
import com.project.movienight.adapters.persistence.entity.toDomain
import com.project.movienight.adapters.persistence.entity.toEntity
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
import com.project.movienight.domain.model.UserPreferences
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.util.UUID
@Repository
class UserPreferencesRepository(
private val jdbc: JdbcTemplate,
) : UserPreferencesRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
UserPreferencesEntity(
userId = UUID.fromString(rs.getString("user_id")),
weightedGenres = rs.getString("weighted_genres"),
plotTypes = rs.getString("plot_types"),
eras = rs.getString("eras"),
castAndDirectors = rs.getString("cast_and_directors"),
moods = rs.getString("moods"),
contentTypes = rs.getString("content_types"),
)
}
override fun save(preferences: UserPreferences): UserPreferences {
val entity = preferences.toEntity()
val updatedRows =
jdbc.update(
"""
UPDATE user_preferences
SET weighted_genres = ?,
plot_types = ?,
eras = ?,
cast_and_directors = ?,
moods = ?,
content_types = ?
WHERE user_id = ?
""".trimIndent(),
entity.weightedGenres,
entity.plotTypes,
entity.eras,
entity.castAndDirectors,
entity.moods,
entity.contentTypes,
entity.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO user_preferences (
user_id,
weighted_genres,
plot_types,
eras,
cast_and_directors,
moods,
content_types
)
VALUES (?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
entity.userId,
entity.weightedGenres,
entity.plotTypes,
entity.eras,
entity.castAndDirectors,
entity.moods,
entity.contentTypes,
)
}
return preferences
}
override fun findByUserId(userId: UUID): UserPreferences? =
jdbc
.query(
"""
SELECT user_id,
weighted_genres,
plot_types,
eras,
cast_and_directors,
moods,
content_types
FROM user_preferences
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
?.toDomain()
}
@@ -0,0 +1,130 @@
package com.project.movienight.adapters.persistence.jdbc
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
import com.project.movienight.domain.model.UserRecommendationWeights
import org.springframework.jdbc.core.JdbcTemplate
import org.springframework.stereotype.Repository
import java.sql.ResultSet
import java.time.LocalDateTime
import java.util.UUID
@Repository
class UserRecommendationWeightsRepository(
private val jdbc: JdbcTemplate,
) : UserRecommendationWeightsRepositoryPort {
private val rowMapper = { rs: ResultSet, _: Int ->
UserRecommendationWeights(
userId = UUID.fromString(rs.getString("user_id")),
relevanceWeight = rs.getDouble("relevance_weight"),
qualityWeight = rs.getDouble("quality_weight"),
contextWeight = rs.getDouble("context_weight"),
noveltyWeight = rs.getDouble("novelty_weight"),
diversityWeight = rs.getDouble("diversity_weight"),
genreVectorWeight = rs.getDouble("genre_vector_weight"),
plotVectorWeight = rs.getDouble("plot_vector_weight"),
moodVectorWeight = rs.getDouble("mood_vector_weight"),
eraVectorWeight = rs.getDouble("era_vector_weight"),
peopleVectorWeight = rs.getDouble("people_vector_weight"),
contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"),
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
)
}
override fun findByUserId(userId: UUID): UserRecommendationWeights? =
jdbc
.query(
"""
SELECT user_id,
relevance_weight,
quality_weight,
context_weight,
novelty_weight,
diversity_weight,
genre_vector_weight,
plot_vector_weight,
mood_vector_weight,
era_vector_weight,
people_vector_weight,
content_type_vector_weight,
updated_at
FROM user_recommendation_weights
WHERE user_id = ?
""".trimIndent(),
rowMapper,
userId,
).firstOrNull()
override fun save(weights: UserRecommendationWeights): UserRecommendationWeights {
val normalized = weights.normalized(updatedAt = LocalDateTime.now())
val updatedRows =
jdbc.update(
"""
UPDATE user_recommendation_weights
SET relevance_weight = ?,
quality_weight = ?,
context_weight = ?,
novelty_weight = ?,
diversity_weight = ?,
genre_vector_weight = ?,
plot_vector_weight = ?,
mood_vector_weight = ?,
era_vector_weight = ?,
people_vector_weight = ?,
content_type_vector_weight = ?,
updated_at = ?
WHERE user_id = ?
""".trimIndent(),
normalized.relevanceWeight,
normalized.qualityWeight,
normalized.contextWeight,
normalized.noveltyWeight,
normalized.diversityWeight,
normalized.genreVectorWeight,
normalized.plotVectorWeight,
normalized.moodVectorWeight,
normalized.eraVectorWeight,
normalized.peopleVectorWeight,
normalized.contentTypeVectorWeight,
normalized.updatedAt,
normalized.userId,
)
if (updatedRows == 0) {
jdbc.update(
"""
INSERT INTO user_recommendation_weights (
user_id,
relevance_weight,
quality_weight,
context_weight,
novelty_weight,
diversity_weight,
genre_vector_weight,
plot_vector_weight,
mood_vector_weight,
era_vector_weight,
people_vector_weight,
content_type_vector_weight,
updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
normalized.userId,
normalized.relevanceWeight,
normalized.qualityWeight,
normalized.contextWeight,
normalized.noveltyWeight,
normalized.diversityWeight,
normalized.genreVectorWeight,
normalized.plotVectorWeight,
normalized.moodVectorWeight,
normalized.eraVectorWeight,
normalized.peopleVectorWeight,
normalized.contentTypeVectorWeight,
normalized.updatedAt,
)
}
return normalized
}
}
@@ -22,36 +22,53 @@ class UserRepository(
email = rs.getString("email"),
provider = rs.getString("provider"),
providerId = rs.getString("provider_id"),
jellyfinUserId = rs.getString("jellyfin_user_id"),
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
)
}
override fun save(user: User): User {
val entity = user.toEntity()
val existingUser = findById(user.id)
val entity =
if (existingUser != null) {
val existingEntity = existingUser.toEntity()
user.toEntity(
provider = existingEntity.provider?.let { AuthProvider.valueOf(it) },
providerId = existingEntity.providerId,
createdAt = existingEntity.createdAt,
)
} else {
user.toEntity()
}
val updatedRows =
jdbc.update(
"""
UPDATE users
SET name = ?, email = ?, provider = ?, provider_id = ?
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, created_at)
VALUES (?, ?, ?, ?, ?, ?)
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,
)
}
@@ -61,17 +78,27 @@ class UserRepository(
override fun findById(id: UUID): User? {
val entities =
jdbc.query(
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?",
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
userEntityRowMapper,
id,
)
return entities.firstOrNull()?.toDomain()
}
override fun findByEmail(email: String): User? {
val entities =
jdbc.query(
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?",
userEntityRowMapper,
email,
)
return entities.firstOrNull()?.toDomain()
}
override fun findAll(): List<User> =
jdbc
.query(
"SELECT id, name, email, provider, provider_id, created_at FROM users",
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
userEntityRowMapper,
).map { it.toDomain() }
@@ -86,7 +113,7 @@ class UserRepository(
val entities =
jdbc.query(
"""
SELECT id, name, email, provider, provider_id, created_at FROM users
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users
WHERE provider = ? AND provider_id = ?
""".trimIndent(),
userEntityRowMapper,
@@ -0,0 +1,38 @@
package com.project.movienight.adapters.persistence.jdbc.support
import java.net.URLDecoder
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
object DelimitedValueCodec {
fun encodeList(values: List<String>): String = values.joinToString("|") { encode(it) }
fun decodeList(value: String?): List<String> =
value
?.takeIf { it.isNotBlank() }
?.split("|")
?.map { decode(it) }
?: emptyList()
fun encodeWeightedMap(values: Map<String, Int>): String =
values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
fun decodeWeightedMap(value: String?): Map<String, Int> {
if (value.isNullOrBlank()) return emptyMap()
return value
.split("|")
.mapNotNull { pair ->
val parts = pair.split(":", limit = 2)
if (parts.size != 2) return@mapNotNull null
val key = decode(parts[0])
val weight = parts[1].toIntOrNull() ?: return@mapNotNull null
key to weight
}.toMap()
}
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8)
}
@@ -0,0 +1,86 @@
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
import com.project.movienight.domain.model.AuthProvider
import com.project.movienight.domain.model.User
import org.slf4j.LoggerFactory
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.user.OAuth2User
import org.springframework.stereotype.Service
@Service
class CustomOAuth2UserService(
private val userRepository: UserRepositoryPort,
private val idGenerator: IdGenerator,
) : DefaultOAuth2UserService() {
companion object {
private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java)
}
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
val oAuth2User = super.loadUser(userRequest)
val registrationId = userRequest.clientRegistration.registrationId
log.debug("Processing OAuth2 login for provider: {}", registrationId)
return try {
val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User)
val user = findOrCreateUser(userInfo)
UserPrincipal.create(user, oAuth2User.attributes)
} catch (e: IllegalArgumentException) {
log.error("OAuth2 authentication failed: ${e.message}", e)
throw OAuth2AuthenticationException("Failed to process OAuth2 user data")
} catch (e: OAuth2AuthenticationException) {
log.error("OAuth2 authentication failed: ${e.message}", e)
throw e
}
}
private fun findOrCreateUser(userInfo: OAuth2UserInfo): User {
val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase())
val existingUser =
userRepository.findByProviderAndProviderId(
provider,
userInfo.getProviderId(),
)
return if (existingUser != null) {
log.debug("User found by provider: {}", userInfo.getProvider())
existingUser
} else {
val userByEmail = userRepository.findByEmail(userInfo.getEmail())
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())
} else {
log.debug("Creating new user for provider: {}", userInfo.getProvider())
val newUser =
User(
id = idGenerator.generateId(),
name = userInfo.getName(),
email = userInfo.getEmail(),
library = null,
)
val entity =
newUser.toEntity(
provider = provider,
providerId = userInfo.getProviderId(),
)
userRepository.save(entity.toDomain())
}
}
}
}
@@ -0,0 +1,17 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
class GoogleOAuth2UserInfo(
private val attributes: Map<String, Any>,
) : OAuth2UserInfo {
override fun getProviderId(): String = attributes["sub"] as String
override fun getEmail(): String = attributes["email"] as String
override fun getName(): String = attributes["name"] as String
override fun getProvider(): String = "google"
override fun getAttributes(): Map<String, Any> = attributes
}
@@ -0,0 +1,21 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.user.OAuth2User
object OAuth2UserInfoFactory {
fun getOAuth2UserInfo(
registrationId: String,
user: OAuth2User,
): OAuth2UserInfo {
val attributes = user.attributes
return when (registrationId.lowercase()) {
"google" -> GoogleOAuth2UserInfo(attributes)
"yandex" -> YandexOAuth2UserInfo(attributes)
"vk" -> VkOAuth2UserInfo(attributes)
else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId")
}
}
}
@@ -0,0 +1,42 @@
package com.project.movienight.adapters.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.web.SecurityFilterChain
@Configuration
@EnableWebSecurity
class SecurityConfiguration(
private val customOAuth2UserService: CustomOAuth2UserService,
) {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http
.oauth2Login { oauth2 ->
oauth2
.userInfoEndpoint { userInfo ->
userInfo.userService(customOAuth2UserService)
}.defaultSuccessUrl("/api/users/me", true)
}.authorizeHttpRequests { auth ->
auth
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
.permitAll()
.requestMatchers("/api/users/me")
.authenticated()
.requestMatchers("/api/**")
.authenticated()
.anyRequest()
.authenticated()
}.headers { headers ->
headers.frameOptions { frameOptions ->
frameOptions.sameOrigin()
}
}.csrf { csrf ->
csrf.disable()
}
return http.build()
}
}
@@ -0,0 +1,44 @@
package com.project.movienight.adapters.security
import com.project.movienight.domain.model.User
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.oauth2.core.user.OAuth2User
import java.util.UUID
class UserPrincipal(
private val user: User,
private val attributes: Map<String, Any>? = null,
) : OAuth2User,
UserDetails {
fun getId(): UUID = user.id
override fun getName(): String = user.name
override fun getAttributes(): Map<String, Any> = attributes ?: emptyMap()
override fun getAuthorities(): Collection<GrantedAuthority> =
listOf(
SimpleGrantedAuthority("ROLE_USER"),
)
override fun getPassword(): String = ""
override fun getUsername(): String = user.email
override fun isAccountNonExpired(): Boolean = true
override fun isAccountNonLocked(): Boolean = true
override fun isCredentialsNonExpired(): Boolean = true
override fun isEnabled(): Boolean = true
companion object {
fun create(
user: User,
attributes: Map<String, Any>? = null,
): UserPrincipal = UserPrincipal(user, attributes)
}
}
@@ -0,0 +1,28 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
class VkOAuth2UserInfo(
private val attributes: Map<String, Any>,
) : OAuth2UserInfo {
override fun getProviderId(): String =
(attributes["response"] as? List<*>)
?.firstOrNull()
?.let { it as? Map<*, *> }
?.get("id")
?.toString() ?: ""
override fun getEmail(): String = attributes["email"]?.toString() ?: ""
override fun getName(): String {
val response = attributes["response"] as? List<*>
val first = response?.firstOrNull() as? Map<*, *>
val firstName = first?.get("first_name")?.toString() ?: ""
val lastName = first?.get("last_name")?.toString() ?: ""
return "$firstName $lastName".trim()
}
override fun getProvider(): String = "vk"
override fun getAttributes(): Map<String, Any> = attributes
}
@@ -0,0 +1,22 @@
package com.project.movienight.adapters.security
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
class YandexOAuth2UserInfo(
private val attributes: Map<String, Any>,
) : OAuth2UserInfo {
override fun getProviderId(): String = attributes["id"]?.toString() ?: ""
override fun getEmail(): String =
(attributes["emails"] as? List<*>)
?.firstOrNull()
?.let { it as? Map<*, *> }
?.get("value")
?.toString() ?: ""
override fun getName(): String = attributes["display_name"]?.toString() ?: ""
override fun getProvider(): String = "yandex"
override fun getAttributes(): Map<String, Any> = attributes
}
@@ -3,6 +3,8 @@ package com.project.movienight.adapters.web
import com.project.movienight.domain.exception.BlockedValueException
import com.project.movienight.domain.exception.DomainException
import com.project.movienight.domain.exception.EntityNotFoundException
import org.slf4j.LoggerFactory
import org.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.ExceptionHandler
import org.springframework.web.bind.annotation.ResponseStatus
@@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
@RestControllerAdvice
class ApiExceptionHandler {
private val log = LoggerFactory.getLogger(javaClass)
@ExceptionHandler(EntityNotFoundException::class)
@ResponseStatus(HttpStatus.NOT_FOUND)
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
ErrorResponse(message = exception.message ?: "Entity not found")
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse {
val traceId = currentTraceId()
log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message)
return ErrorResponse(
message = exception.message ?: "Entity not found",
traceId = traceId,
)
}
@ExceptionHandler(BlockedValueException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
ErrorResponse(message = exception.message ?: "Blocked value")
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
val traceId = currentTraceId()
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
return ErrorResponse(
message = exception.message ?: "Blocked value",
traceId = traceId,
)
}
@ExceptionHandler(DomainException::class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
fun handleDomainException(exception: DomainException): ErrorResponse =
ErrorResponse(message = exception.message ?: "Domain error")
fun handleDomainException(exception: DomainException): ErrorResponse {
val traceId = currentTraceId()
log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message)
return ErrorResponse(
message = exception.message ?: "Domain error",
traceId = traceId,
)
}
@ExceptionHandler(Exception::class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
fun handleUnexpectedException(exception: Exception): ErrorResponse {
val traceId = currentTraceId()
log.error("Unexpected error: traceId='{}'", traceId, exception)
return ErrorResponse(
message = "Internal server error",
traceId = traceId,
)
}
private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
}
data class ErrorResponse(
val message: String,
val traceId: String,
)
@@ -8,23 +8,38 @@ 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 org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
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.RequestParam
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,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@@ -36,6 +51,16 @@ class FilmController(
CreateFilmCommand(
title = request.title,
description = request.description,
contentType = request.contentType.toContentTypeOrFilm(),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
directors = request.directors,
imdbRating = request.imdbRating,
platformRating = request.platformRating,
externalUrl = request.externalUrl,
jellyfinItemId = request.jellyfinItemId,
jellyfinLibraryId = request.jellyfinLibraryId,
),
),
)
@@ -52,6 +77,16 @@ class FilmController(
EditFilmCommand(
title = request.title,
description = request.description,
contentType = request.contentType.toContentTypeOrFilm(),
releaseYear = request.releaseYear,
genres = request.genres,
cast = request.cast,
directors = request.directors,
imdbRating = request.imdbRating,
platformRating = request.platformRating,
externalUrl = request.externalUrl,
jellyfinItemId = request.jellyfinItemId,
jellyfinLibraryId = request.jellyfinLibraryId,
),
),
)
@@ -61,4 +96,24 @@ class FilmController(
fun delete(
@PathVariable id: UUID,
) = deleteFilmUseCase.delete(id)
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
@GetMapping
fun getAll(): List<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
@GetMapping("/search")
fun searchByTitle(
@RequestParam title: String,
): ResponseEntity<FilmResponse> {
val film = searchFilmByTitleUseCase.searchByTitle(title)
return if (film != null) {
ResponseEntity.ok(FilmResponse.fromDomain(film))
} else {
ResponseEntity.notFound().build()
}
}
}
@@ -2,14 +2,21 @@ 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.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.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
@@ -26,8 +33,11 @@ import java.util.UUID
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,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@@ -54,6 +64,11 @@ class FilmLibraryController(
),
)
@GetMapping("/entries")
fun list(
@PathVariable userId: UUID,
): List<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun addFilm(
@@ -69,6 +84,20 @@ class FilmLibraryController(
),
)
@PostMapping("/films/{filmId}/viewed")
fun markViewed(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): FilmLibraryResponse =
FilmLibraryResponse.fromDomain(
markFilmViewedUseCase.markViewed(
MarkFilmViewedCommand(
userId = userId,
filmId = filmId,
),
),
)
@DeleteMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun removeFilm(
@@ -82,4 +111,31 @@ 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) }
}
}
@@ -0,0 +1,46 @@
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.RateFilmCommand
import com.project.movienight.application.ports.input.RateFilmUseCase
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/ratings")
class FilmRatingController(
private val rateFilmUseCase: RateFilmUseCase,
private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
) {
@PostMapping("/films/{filmId}")
@ResponseStatus(HttpStatus.CREATED)
fun rate(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
@RequestBody request: RateFilmRequest,
): FilmRatingResponse =
FilmRatingResponse.fromDomain(
rateFilmUseCase.rate(
RateFilmCommand(
userId = userId,
filmId = filmId,
score = request.score,
note = request.note,
),
),
)
@GetMapping
fun list(
@PathVariable userId: UUID,
): List<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
}
@@ -0,0 +1,56 @@
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.config.JellyfinIntegrationProperties
import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus
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.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 properties: JellyfinIntegrationProperties,
) {
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
@PostMapping("/events")
@ResponseStatus(HttpStatus.OK)
fun receiveEvent(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@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")
}
}
log.debug(
"Received Jellyfin event {} for user {} item {}",
request.eventId,
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,
)
}
}
@@ -0,0 +1,21 @@
package com.project.movienight.adapters.web
import com.project.movienight.application.services.JellyfinSyncService
import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping
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,
) {
@PostMapping("/sync")
fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
@GetMapping("/sync-state")
fun syncState(): List<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
}
@@ -0,0 +1,92 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
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.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
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RequestParam
import org.springframework.web.bind.annotation.RestController
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendations")
class RecommendationController(
private val getRecommendationsUseCase: GetRecommendationsUseCase,
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
private val jellyfinProperties: JellyfinIntegrationProperties,
) {
@GetMapping
fun recommend(
@PathVariable userId: UUID,
@RequestParam(required = false) contentType: String?,
@RequestParam(required = false) mood: String?,
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
@RequestParam(required = false, defaultValue = "10") limit: Int,
): List<RecommendationResponse> =
getRecommendationsUseCase
.recommend(
RecommendationQuery(
userId = userId,
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
mood = mood,
libraryOnly = libraryOnly,
limit = limit,
),
).map { recommendation ->
RecommendationResponse.fromDomain(
recommendation = recommendation,
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
)
}
@PostMapping("/{filmId}/accept")
fun accept(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): RecommendationEventResponse =
RecommendationEventResponse.fromDomain(
acceptRecommendationUseCase.accept(
AcceptRecommendationCommand(
userId = userId,
filmId = filmId,
),
),
)
@PostMapping("/{filmId}/reject")
fun reject(
@PathVariable userId: UUID,
@PathVariable filmId: UUID,
): RecommendationEventResponse =
RecommendationEventResponse.fromDomain(
rejectRecommendationUseCase.reject(
RejectRecommendationCommand(
userId = userId,
filmId = filmId,
),
),
)
private fun buildWatchUrl(jellyfinItemId: String?): String? {
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
return null
}
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
return "$baseUrl/web/#/details?id=$encodedItemId"
}
}
@@ -0,0 +1,52 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationStyle
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.RestController
import java.util.Locale
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-onboarding")
class RecommendationOnboardingController(
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
) {
@PostMapping
fun complete(
@PathVariable userId: UUID,
@RequestBody request: RecommendationOnboardingRequest,
): RecommendationOnboardingResponse =
RecommendationOnboardingResponse.fromApplication(
completeRecommendationOnboardingUseCase.complete(
CompleteRecommendationOnboardingCommand(
userId = userId,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes = request.contentTypes.mapNotNull(::parseContentType),
likedFilmIds = request.likedFilmIds,
dislikedFilmIds = request.dislikedFilmIds,
libraryFilmIds = request.libraryFilmIds,
watchedFilmIds = request.watchedFilmIds,
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
),
),
)
private fun parseContentType(value: String): ContentType? =
runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull()
private fun parseRecommendationStyle(value: String): RecommendationStyle =
runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) }
.getOrDefault(RecommendationStyle.BALANCED)
}
@@ -0,0 +1,27 @@
package com.project.movienight.adapters.web
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.MDC
import org.springframework.stereotype.Component
import org.springframework.web.filter.OncePerRequestFilter
import java.util.UUID
@Component
class TraceIdFilter : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain,
) {
val traceId = UUID.randomUUID().toString()
MDC.put("traceId", traceId)
try {
filterChain.doFilter(request, response)
} finally {
MDC.remove("traceId")
}
}
}
@@ -8,8 +8,11 @@ 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 org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PatchMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
@@ -25,6 +28,8 @@ class UserController(
private val createUserUseCase: CreateUserUseCase,
private val editUserUseCase: EditUserUseCase,
private val deleteUserUseCase: DeleteUserUseCase,
private val getUserByIdUseCase: GetUserByIdUseCase,
private val getAllUsersUseCase: GetAllUsersUseCase,
) {
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
@@ -40,6 +45,14 @@ class UserController(
),
)
@GetMapping
fun getAll(): List<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
@GetMapping("/{id}")
fun getById(
@PathVariable id: UUID,
): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
@PatchMapping("/{id}")
fun edit(
@PathVariable id: UUID,
@@ -51,6 +64,7 @@ class UserController(
command =
EditUserCommand(
name = request.name,
jellyfinUserId = request.jellyfinUserId,
),
),
)
@@ -0,0 +1,53 @@
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 org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/preferences")
class UserPreferencesController(
private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
) {
@PutMapping
fun upsert(
@PathVariable userId: UUID,
@RequestBody request: UpsertUserPreferencesRequest,
): UserPreferencesResponse =
UserPreferencesResponse.fromDomain(
upsertUserPreferencesUseCase.upsert(
UpsertUserPreferencesCommand(
userId = userId,
weightedGenres = request.weightedGenres,
plotTypes = request.plotTypes,
eras = request.eras,
castAndDirectors = request.castAndDirectors,
moods = request.moods,
contentTypes =
request.contentTypes.mapNotNull {
runCatching {
ContentType.valueOf(
it,
)
}.getOrNull()
},
),
),
)
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
}
@@ -0,0 +1,53 @@
package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest
import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PutMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
@RestController
@RequestMapping("/api/users/{userId}/recommendation-weights")
class UserRecommendationWeightsController(
private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase,
private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase,
) {
@GetMapping
fun get(
@PathVariable userId: UUID,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
getUserRecommendationWeightsUseCase.get(userId),
)
@PutMapping
fun update(
@PathVariable userId: UUID,
@RequestBody request: UpdateUserRecommendationWeightsRequest,
): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse.fromDomain(
updateUserRecommendationWeightsUseCase.update(
UpdateUserRecommendationWeightsCommand(
userId = userId,
relevanceWeight = request.relevanceWeight,
qualityWeight = request.qualityWeight,
contextWeight = request.contextWeight,
noveltyWeight = request.noveltyWeight,
diversityWeight = request.diversityWeight,
genreVectorWeight = request.genreVectorWeight,
plotVectorWeight = request.plotVectorWeight,
moodVectorWeight = request.moodVectorWeight,
eraVectorWeight = request.eraVectorWeight,
peopleVectorWeight = request.peopleVectorWeight,
contentTypeVectorWeight = request.contentTypeVectorWeight,
),
),
)
}
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
data class CreateFilmRequest(
val title: String,
val description: String,
val contentType: String = "FILM",
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
data class EditFilmRequest(
val title: String,
val description: String,
val contentType: String = "FILM",
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)
@@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request
data class EditUserRequest(
val name: String,
val jellyfinUserId: String? = null,
)
@@ -0,0 +1,21 @@
package com.project.movienight.adapters.web.dto.request
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.OffsetDateTime
data class JellyfinEventRequest(
@JsonProperty("event_id")
val eventId: String,
@JsonProperty("event_type")
val eventType: String,
@JsonProperty("occurred_at")
val occurredAt: OffsetDateTime,
@JsonProperty("jellyfin_user_id")
val jellyfinUserId: String,
@JsonProperty("item_id")
val itemId: String,
@JsonProperty("payload_version")
val payloadVersion: Int = 1,
@JsonProperty("payload")
val payload: Map<String, Any>? = null,
)
@@ -0,0 +1,6 @@
package com.project.movienight.adapters.web.dto.request
data class RateFilmRequest(
val score: Int,
val note: String? = null,
)
@@ -0,0 +1,17 @@
package com.project.movienight.adapters.web.dto.request
import java.util.UUID
data class RecommendationOnboardingRequest(
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<String> = emptyList(),
val likedFilmIds: List<UUID> = emptyList(),
val dislikedFilmIds: List<UUID> = emptyList(),
val libraryFilmIds: List<UUID> = emptyList(),
val watchedFilmIds: List<UUID> = emptyList(),
val recommendationStyle: String = "BALANCED",
)
@@ -0,0 +1,15 @@
package com.project.movienight.adapters.web.dto.request
data class UpdateUserRecommendationWeightsRequest(
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
)
@@ -0,0 +1,10 @@
package com.project.movienight.adapters.web.dto.request
data class UpsertUserPreferencesRequest(
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<String> = emptyList(),
)
@@ -9,6 +9,7 @@ data class FilmLibraryResponse(
val filmId: UUID,
val comment: String?,
val isViewed: Boolean,
val watchedAt: java.time.LocalDateTime?,
) {
companion object {
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
@@ -18,6 +19,7 @@ data class FilmLibraryResponse(
filmId = filmLibrary.filmId,
comment = filmLibrary.comment,
isViewed = filmLibrary.isViewed,
watchedAt = filmLibrary.watchedAt,
)
}
}
@@ -0,0 +1,28 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.FilmRating
import java.time.LocalDateTime
import java.util.UUID
data class FilmRatingResponse(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val score: Int,
val note: String?,
val createdAt: LocalDateTime,
val updatedAt: LocalDateTime,
) {
companion object {
fun fromDomain(rating: FilmRating): FilmRatingResponse =
FilmRatingResponse(
id = rating.id,
userId = rating.userId,
filmId = rating.filmId,
score = rating.score,
note = rating.note,
createdAt = rating.createdAt,
updatedAt = rating.updatedAt,
)
}
}
@@ -1,5 +1,6 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film
import java.util.UUID
@@ -7,6 +8,16 @@ data class FilmResponse(
val id: UUID,
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 imdbRating: Double?,
val platformRating: Double?,
val externalUrl: String?,
val jellyfinItemId: String?,
val jellyfinLibraryId: String?,
) {
companion object {
fun fromDomain(film: Film): FilmResponse =
@@ -14,6 +25,16 @@ data class FilmResponse(
id = film.id,
title = film.title,
description = film.description,
contentType = film.contentType,
releaseYear = film.releaseYear,
genres = film.genres,
cast = film.cast,
directors = film.directors,
imdbRating = film.imdbRating,
platformRating = film.platformRating,
externalUrl = film.externalUrl,
jellyfinItemId = film.jellyfinItemId,
jellyfinLibraryId = film.jellyfinLibraryId,
)
}
}
@@ -0,0 +1,37 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
import java.time.LocalDateTime
import java.util.UUID
data class RecommendationEventResponse(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val eventType: RecommendationEventType,
val score: Double?,
val relevanceScore: Double?,
val qualityScore: Double?,
val contextScore: Double?,
val noveltyScore: Double?,
val diversityScore: Double?,
val createdAt: LocalDateTime,
) {
companion object {
fun fromDomain(event: RecommendationEvent): RecommendationEventResponse =
RecommendationEventResponse(
id = event.id,
userId = event.userId,
filmId = event.filmId,
eventType = event.eventType,
score = event.score,
relevanceScore = event.relevanceScore,
qualityScore = event.qualityScore,
contextScore = event.contextScore,
noveltyScore = event.noveltyScore,
diversityScore = event.diversityScore,
createdAt = event.createdAt,
)
}
}
@@ -0,0 +1,27 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
import java.util.UUID
data class RecommendationOnboardingResponse(
val userId: UUID,
val preferences: UserPreferencesResponse,
val weights: UserRecommendationWeightsResponse,
val likedFilmsCount: Int,
val dislikedFilmsCount: Int,
val libraryFilmsCount: Int,
val watchedFilmsCount: Int,
) {
companion object {
fun fromApplication(result: RecommendationOnboardingResult): RecommendationOnboardingResponse =
RecommendationOnboardingResponse(
userId = result.userId,
preferences = UserPreferencesResponse.fromDomain(result.preferences),
weights = UserRecommendationWeightsResponse.fromDomain(result.weights),
likedFilmsCount = result.likedFilmsCount,
dislikedFilmsCount = result.dislikedFilmsCount,
libraryFilmsCount = result.libraryFilmsCount,
watchedFilmsCount = result.watchedFilmsCount,
)
}
}
@@ -0,0 +1,32 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.RecommendationResult
import java.util.UUID
data class RecommendationResponse(
val filmId: UUID,
val title: String,
val score: Double,
val reasons: List<String>,
val jellyfinItemId: String?,
val watchUrl: String?,
val film: FilmResponse,
) {
companion object {
fun fromDomain(
recommendation: RecommendationResult,
watchUrl: String?,
): RecommendationResponse {
val film = recommendation.film
return RecommendationResponse(
filmId = film.id,
title = film.title,
score = recommendation.score,
reasons = recommendation.reasons,
jellyfinItemId = film.jellyfinItemId,
watchUrl = watchUrl,
film = FilmResponse.fromDomain(film),
)
}
}
}
@@ -0,0 +1,28 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
data class UserPreferencesResponse(
val userId: UUID,
val weightedGenres: Map<String, Int>,
val plotTypes: List<String>,
val eras: List<String>,
val castAndDirectors: List<String>,
val moods: List<String>,
val contentTypes: List<ContentType>,
) {
companion object {
fun fromDomain(preferences: UserPreferences): UserPreferencesResponse =
UserPreferencesResponse(
userId = preferences.userId,
weightedGenres = preferences.weightedGenres,
plotTypes = preferences.plotTypes,
eras = preferences.eras,
castAndDirectors = preferences.castAndDirectors,
moods = preferences.moods,
contentTypes = preferences.contentTypes,
)
}
}
@@ -0,0 +1,40 @@
package com.project.movienight.adapters.web.dto.response
import com.project.movienight.domain.model.UserRecommendationWeights
import java.time.LocalDateTime
import java.util.UUID
data class UserRecommendationWeightsResponse(
val userId: UUID,
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
val updatedAt: LocalDateTime,
) {
companion object {
fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse =
UserRecommendationWeightsResponse(
userId = weights.userId,
relevanceWeight = weights.relevanceWeight,
qualityWeight = weights.qualityWeight,
contextWeight = weights.contextWeight,
noveltyWeight = weights.noveltyWeight,
diversityWeight = weights.diversityWeight,
genreVectorWeight = weights.genreVectorWeight,
plotVectorWeight = weights.plotVectorWeight,
moodVectorWeight = weights.moodVectorWeight,
eraVectorWeight = weights.eraVectorWeight,
peopleVectorWeight = weights.peopleVectorWeight,
contentTypeVectorWeight = weights.contentTypeVectorWeight,
updatedAt = weights.updatedAt,
)
}
}
@@ -7,6 +7,7 @@ data class UserResponse(
val id: UUID,
val name: String,
val email: String,
val jellyfinUserId: String?,
) {
companion object {
fun fromDomain(user: User): UserResponse =
@@ -14,6 +15,7 @@ data class UserResponse(
id = user.id,
name = user.name,
email = user.email,
jellyfinUserId = user.jellyfinUserId,
)
}
}
@@ -1,6 +1,7 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmLibrary
import java.time.LocalDateTime
import java.util.UUID
interface CreateFilmLibraryUseCase {
@@ -21,6 +22,16 @@ 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
}
@@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase {
data class GetFilmLibraryQuery(
val userId: UUID,
)
interface ListFilmLibraryEntriesUseCase {
fun list(userId: UUID): List<FilmLibrary>
}
@@ -0,0 +1,19 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.FilmRating
import java.util.UUID
interface RateFilmUseCase {
fun rate(command: RateFilmCommand): FilmRating
}
data class RateFilmCommand(
val userId: UUID,
val filmId: UUID,
val score: Int,
val note: String? = null,
)
interface GetFilmRatingsUseCase {
fun getRatings(userId: UUID): List<FilmRating>
}
@@ -1,5 +1,6 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.Film
import java.util.UUID
@@ -10,6 +11,16 @@ interface CreateFilmUseCase {
data class CreateFilmCommand(
val title: String,
val description: String,
val contentType: ContentType = ContentType.FILM,
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)
interface EditFilmUseCase {
@@ -22,8 +33,30 @@ interface EditFilmUseCase {
data class EditFilmCommand(
val title: String,
val description: String,
val contentType: ContentType = ContentType.FILM,
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
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,36 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationResult
import java.util.UUID
interface GetRecommendationsUseCase {
fun recommend(query: RecommendationQuery): List<RecommendationResult>
}
data class RecommendationQuery(
val userId: UUID,
val contentType: ContentType? = null,
val mood: String? = null,
val libraryOnly: Boolean = false,
val limit: Int = 10,
)
interface AcceptRecommendationUseCase {
fun accept(command: AcceptRecommendationCommand): RecommendationEvent
}
data class AcceptRecommendationCommand(
val userId: UUID,
val filmId: UUID,
)
interface RejectRecommendationUseCase {
fun reject(command: RejectRecommendationCommand): RecommendationEvent
}
data class RejectRecommendationCommand(
val userId: UUID,
val filmId: UUID,
)
@@ -0,0 +1,36 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.RecommendationStyle
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
import java.util.UUID
interface CompleteRecommendationOnboardingUseCase {
fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult
}
data class CompleteRecommendationOnboardingCommand(
val userId: UUID,
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
val likedFilmIds: List<UUID> = emptyList(),
val dislikedFilmIds: List<UUID> = emptyList(),
val libraryFilmIds: List<UUID> = emptyList(),
val watchedFilmIds: List<UUID> = emptyList(),
val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED,
)
data class RecommendationOnboardingResult(
val userId: UUID,
val preferences: UserPreferences,
val weights: UserRecommendationWeights,
val likedFilmsCount: Int,
val dislikedFilmsCount: Int,
val libraryFilmsCount: Int,
val watchedFilmsCount: Int,
)
@@ -0,0 +1,23 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.ContentType
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
interface UpsertUserPreferencesUseCase {
fun upsert(command: UpsertUserPreferencesCommand): UserPreferences
}
data class UpsertUserPreferencesCommand(
val userId: UUID,
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
)
interface GetUserPreferencesUseCase {
fun get(userId: UUID): UserPreferences?
}
@@ -0,0 +1,27 @@
package com.project.movienight.application.ports.input
import com.project.movienight.domain.model.UserRecommendationWeights
import java.util.UUID
interface GetUserRecommendationWeightsUseCase {
fun get(userId: UUID): UserRecommendationWeights
}
interface UpdateUserRecommendationWeightsUseCase {
fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights
}
data class UpdateUserRecommendationWeightsCommand(
val userId: UUID,
val relevanceWeight: Double,
val qualityWeight: Double,
val contextWeight: Double,
val noveltyWeight: Double,
val diversityWeight: Double,
val genreVectorWeight: Double,
val plotVectorWeight: Double,
val moodVectorWeight: Double,
val eraVectorWeight: Double,
val peopleVectorWeight: Double,
val contentTypeVectorWeight: Double,
)
@@ -21,8 +21,17 @@ interface EditUserUseCase {
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,13 @@
package com.project.movienight.application.ports.input.security
interface OAuth2UserInfo {
fun getProviderId(): String
fun getEmail(): String
fun getName(): String
fun getProvider(): String
fun getAttributes(): Map<String, Any>
}
@@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort {
fun findById(id: UUID): FilmLibrary?
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmLibrary?
fun findAll(): List<FilmLibrary>
fun deleteById(id: UUID)
@@ -0,0 +1,15 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.FilmRating
import java.util.UUID
interface FilmRatingRepositoryPort {
fun save(rating: FilmRating): FilmRating
fun findByUserId(userId: UUID): List<FilmRating>
fun findByUserIdAndFilmId(
userId: UUID,
filmId: UUID,
): FilmRating?
}
@@ -8,7 +8,13 @@ interface FilmRepositoryPort {
fun findById(id: UUID): Film?
fun findByJellyfinItemId(jellyfinItemId: String): Film?
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
fun findAll(): List<Film>
fun findByTitle(title: String): Film?
fun deleteById(id: UUID)
}
@@ -0,0 +1,12 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.JellyfinSyncState
import java.util.UUID
interface JellyfinSyncStateRepositoryPort {
fun save(state: JellyfinSyncState): JellyfinSyncState
fun findByUserId(userId: UUID): JellyfinSyncState?
fun findAll(): List<JellyfinSyncState>
}
@@ -0,0 +1,15 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.RecommendationEvent
import java.util.UUID
interface RecommendationEventRepositoryPort {
fun save(event: RecommendationEvent): RecommendationEvent
fun findByUserId(userId: UUID): List<RecommendationEvent>
fun findLatestRecommended(
userId: UUID,
filmId: UUID,
): RecommendationEvent?
}
@@ -0,0 +1,10 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.UserPreferences
import java.util.UUID
interface UserPreferencesRepositoryPort {
fun save(preferences: UserPreferences): UserPreferences
fun findByUserId(userId: UUID): UserPreferences?
}
@@ -0,0 +1,10 @@
package com.project.movienight.application.ports.output
import com.project.movienight.domain.model.UserRecommendationWeights
import java.util.UUID
interface UserRecommendationWeightsRepositoryPort {
fun findByUserId(userId: UUID): UserRecommendationWeights?
fun save(weights: UserRecommendationWeights): UserRecommendationWeights
}
@@ -9,6 +9,8 @@ interface UserRepositoryPort {
fun findById(id: UUID): User?
fun findByEmail(email: String): User?
fun findAll(): List<User>
fun deleteById(id: UUID)
@@ -1,11 +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.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.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
@@ -20,70 +24,105 @@ import java.util.UUID
class FilmLibraryService(
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : CreateFilmLibraryUseCase,
AddFilmToLibraryUseCase,
MarkFilmViewedUseCase,
RemoveFilmFromLibraryUseCase,
GetFilmLibraryUseCase {
GetFilmLibraryUseCase,
ListFilmLibraryEntriesUseCase {
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
val existingLibrary = findByUserId(command.userId)
if (existingLibrary != null) {
return existingLibrary
}
return filmLibraryRepository.save(
FilmLibrary(
id = idGenerator.generateId(),
userId = command.userId,
filmId = idGenerator.generateId(),
comment = command.name,
isViewed = false,
),
)
findByUserId(command.userId)?.let { return it }
throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
}
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
val existingLibrary = findByUserId(command.userId)
if (existingLibrary == null) {
return filmLibraryRepository.save(
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
if (existingEntry != null) {
val saved =
filmLibraryRepository.save(
existingEntry.copy(
isViewed = false,
watchedAt = null,
),
)
businessMetricsService.recordLibraryEvent()
return saved
}
val saved =
filmLibraryRepository.save(
FilmLibrary(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
comment = null,
isViewed = false,
watchedAt = null,
),
)
}
return filmLibraryRepository.save(
existingLibrary.copy(
filmId = command.filmId,
isViewed = false,
),
)
businessMetricsService.recordLibraryEvent()
return saved
}
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
val existingLibrary =
findByUserId(command.userId)
?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
if (command.libraryId != null) {
filmLibraryRepository.findById(command.libraryId)
?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
} else {
findByUserAndFilmId(command.userId, command.filmId)
?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString())
}
if (command.libraryId != null && command.libraryId != existingLibrary.id) {
throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
}
if (existingLibrary.filmId != command.filmId) {
if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
throw DomainException("Film with id ${command.filmId} not found in user's library")
}
filmLibraryRepository.deleteById(existingLibrary.id)
businessMetricsService.recordLibraryEvent()
return existingLibrary
}
override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
val saved =
if (existingEntry == null) {
filmLibraryRepository.save(
FilmLibrary(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
comment = null,
isViewed = true,
watchedAt = watchedAt,
),
)
} else {
filmLibraryRepository.save(
existingEntry.copy(
isViewed = true,
watchedAt = watchedAt,
),
)
}
businessMetricsService.recordLibraryEvent()
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<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
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 }
}
@@ -0,0 +1,57 @@
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.RateFilmCommand
import com.project.movienight.application.ports.input.RateFilmUseCase
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
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.FilmRating
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.UUID
@Service
class FilmRatingService(
private val filmRepository: FilmRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) : RateFilmUseCase,
GetFilmRatingsUseCase {
override fun rate(command: RateFilmCommand): FilmRating {
if (command.score !in 1..10) {
throw DomainException("Film rating score must be between 1 and 10")
}
filmRepository.findById(command.filmId)
?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString())
val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId)
val now = LocalDateTime.now()
val rating =
if (existingRating == null) {
FilmRating(
id = idGenerator.generateId(),
userId = command.userId,
filmId = command.filmId,
score = command.score,
note = command.note,
createdAt = now,
updatedAt = now,
)
} else {
existingRating.copy(score = command.score, note = command.note, updatedAt = now)
}
val savedRating = filmRatingRepository.save(rating)
businessMetricsService.recordRatingSubmitted()
return savedRating
}
override fun getRatings(userId: UUID): List<FilmRating> = filmRatingRepository.findByUserId(userId)
}
@@ -5,12 +5,19 @@ 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.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 org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.util.UUID
@@ -19,47 +26,181 @@ class FilmService(
private val filmRepository: FilmRepositoryPort,
private val idGenerator: IdGenerator,
private val filmConfig: FilmServiceProperties,
private val meterRegistry: MeterRegistry,
) : CreateFilmUseCase,
EditFilmUseCase,
DeleteFilmUseCase {
override fun create(command: CreateFilmCommand): Film {
if (filmConfig.isBlocked(command.title)) {
throw BlockedValueException(target = "Film", field = "title")
}
if (filmConfig.isBlocked(command.description)) {
throw BlockedValueException(target = "Film", field = "description")
}
DeleteFilmUseCase,
GetFilmByIdUseCase,
GetAllFilmsUseCase,
SearchFilmByTitleUseCase {
private val log = LoggerFactory.getLogger(javaClass)
val film =
Film(
id = idGenerator.generateId(),
title = command.title,
description = command.description,
override fun create(command: CreateFilmCommand): Film {
val sample = Timer.start(meterRegistry)
try {
log.debug(
"Create film request received: title='{}', descriptionLength={}",
command.title,
command.description.length,
)
return filmRepository.save(film)
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)
}
}
override fun edit(
id: UUID,
command: EditFilmCommand,
): Film {
if (filmConfig.isBlocked(command.title)) {
throw BlockedValueException(target = "Film", field = "title")
val sample = Timer.start(meterRegistry)
try {
log.debug("Edit film with id: {}", id)
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")
}
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 =
film.copy(
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)
filmEditedCounter.increment()
return saved
} finally {
sample.stop(editFilmTimer)
}
if (filmConfig.isBlocked(command.description)) {
throw BlockedValueException(target = "Film", field = "description")
}
var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
film = film.copy(title = command.title, description = command.description)
return filmRepository.save(film)
}
override fun delete(id: UUID) {
val sample = Timer.start(meterRegistry)
try {
log.debug("Delete film with id: {}", 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)
}
}
override fun getById(id: UUID): Film =
filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
filmRepository.deleteById(id)
}
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)
}
@@ -0,0 +1,81 @@
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.MarkFilmViewedCommand
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service
import java.time.OffsetDateTime
@Service
class JellyfinEventService(
private val jellyfinEventRepository: JellyfinEventRepository,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
private val objectMapper: ObjectMapper,
private val businessMetricsService: BusinessMetricsService,
) {
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) }
val inserted =
jellyfinEventRepository.save(
eventId = eventId,
serverId = serverId,
eventType = eventType,
occurredAt = occurredAt,
jellyfinUserId = jellyfinUserId,
jellyfinItemId = itemId,
payload = payloadJson,
)
if (inserted != 1) {
return
}
try {
if (playbackEventTypes.contains(eventType)) {
val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
if (localUser == null) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordJellyfinUnmappedUser()
return
}
val film = filmRepository.findByJellyfinItemId(itemId)
if (film == null) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordBackendWriteFailure()
return
}
markFilmViewedUseCase.markViewed(
MarkFilmViewedCommand(
userId = localUser.id,
filmId = film.id,
watchedAt = occurredAt.toLocalDateTime(),
),
)
businessMetricsService.recordLibraryEvent()
}
} catch (
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
) {
jellyfinEventRepository.delete(eventId)
businessMetricsService.recordBackendWriteFailure()
throw ex
}
}
}
@@ -0,0 +1,153 @@
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.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
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.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service
import java.time.Duration
import java.time.Instant
import java.time.LocalDateTime
@Service
class JellyfinSyncService(
private val properties: JellyfinIntegrationProperties,
private val jellyfinApiClient: JellyfinApiClient,
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
private val idGenerator: IdGenerator,
private val businessMetricsService: BusinessMetricsService,
) {
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
fun scheduledSync() {
if (properties.enabled) {
syncNow()
}
}
fun syncNow(): JellyfinSyncSummary {
if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
}
val startedAt = Instant.now()
val remoteUsers = jellyfinApiClient.fetchUsers()
val localUsersByJellyfinId =
userRepository
.findAll()
.mapNotNull { user ->
user.jellyfinUserId?.let { it to user }
}.toMap()
var syncedUsers = 0
var skippedUsers = 0
var syncedItems = 0
remoteUsers.forEach { remoteUser ->
val localUser = localUsersByJellyfinId[remoteUser.id]
if (localUser == null) {
skippedUsers += 1
return@forEach
}
val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id)
items.forEach { item ->
syncItem(localUser.id, item)
syncedItems += 1
}
val now = LocalDateTime.now()
syncStateRepository.save(
JellyfinSyncState(
userId = localUser.id,
lastSyncedAt = now,
lastSuccessfulSyncAt = now,
lastError = null,
syncedItemCount = items.size,
),
)
syncedUsers += 1
}
val summary =
JellyfinSyncSummary(
syncedUsers = syncedUsers,
skippedUsers = skippedUsers,
syncedItems = syncedItems,
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
)
businessMetricsService.recordJellyfinSync(summary)
return summary
}
fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
private fun syncItem(
userId: java.util.UUID,
item: JellyfinLibraryItemSnapshot,
) {
val film =
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
title = item.title,
description = item.description,
contentType = item.contentType,
releaseYear = item.releaseYear,
genres = item.genres,
cast = item.cast,
directors = item.directors,
imdbRating = item.imdbRating,
platformRating = item.platformRating,
externalUrl = item.externalUrl,
jellyfinItemId = item.jellyfinItemId,
jellyfinLibraryId = item.jellyfinLibraryId,
) ?: Film(
id = idGenerator.generateId(),
title = item.title,
description = item.description,
contentType = item.contentType,
releaseYear = item.releaseYear,
genres = item.genres,
cast = item.cast,
directors = item.directors,
imdbRating = item.imdbRating,
platformRating = item.platformRating,
externalUrl = item.externalUrl,
jellyfinItemId = item.jellyfinItemId,
jellyfinLibraryId = item.jellyfinLibraryId,
)
val savedFilm = 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,
),
)
}
}
}
@@ -0,0 +1,150 @@
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.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
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.FilmRating
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.UUID
@Service
class RecommendationOnboardingService(
private val userRepository: UserRepositoryPort,
private val filmRepository: FilmRepositoryPort,
private val userPreferencesRepository: UserPreferencesRepositoryPort,
private val filmRatingRepository: FilmRatingRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val idGenerator: IdGenerator,
) : CompleteRecommendationOnboardingUseCase {
override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult {
userRepository.findById(command.userId)
?: throw EntityNotFoundException(entity = "User", id = command.userId.toString())
val filmIds =
(
command.likedFilmIds +
command.dislikedFilmIds +
command.libraryFilmIds +
command.watchedFilmIds
).distinct()
ensureFilmsExist(filmIds)
val preferences =
userPreferencesRepository.save(
UserPreferences(
userId = command.userId,
weightedGenres = command.weightedGenres,
plotTypes = command.plotTypes,
eras = command.eras,
castAndDirectors = command.castAndDirectors,
moods = command.moods,
contentTypes = command.contentTypes,
),
)
command.likedFilmIds.distinct().forEach { filmId ->
saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE)
}
command.dislikedFilmIds.distinct().forEach { filmId ->
saveRating(
userId = command.userId,
filmId = filmId,
score = DISLIKED_SCORE,
note = ONBOARDING_DISLIKED_NOTE,
)
}
command.libraryFilmIds.distinct().forEach { filmId ->
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false)
}
command.watchedFilmIds.distinct().forEach { filmId ->
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true)
}
val weights =
userRecommendationWeightsRepository.save(
UserRecommendationWeights.forStyle(
userId = command.userId,
style = command.recommendationStyle,
),
)
return RecommendationOnboardingResult(
userId = command.userId,
preferences = preferences,
weights = weights,
likedFilmsCount = command.likedFilmIds.distinct().size,
dislikedFilmsCount = command.dislikedFilmIds.distinct().size,
libraryFilmsCount = command.libraryFilmIds.distinct().size,
watchedFilmsCount = command.watchedFilmIds.distinct().size,
)
}
private fun ensureFilmsExist(filmIds: List<UUID>) {
filmIds.forEach { filmId ->
filmRepository.findById(filmId)
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
}
}
private fun saveRating(
userId: UUID,
filmId: UUID,
score: Int,
note: String,
): FilmRating {
val now = LocalDateTime.now()
val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId)
return filmRatingRepository.save(
existing?.copy(score = score, note = note, updatedAt = now)
?: FilmRating(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
score = score,
note = note,
createdAt = now,
updatedAt = now,
),
)
}
private fun saveLibraryEntry(
userId: UUID,
filmId: UUID,
isViewed: Boolean,
): FilmLibrary {
val watchedAt = LocalDateTime.now().takeIf { isViewed }
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
return filmLibraryRepository.save(
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
?: FilmLibrary(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
comment = null,
isViewed = isViewed,
watchedAt = watchedAt,
),
)
}
private companion object {
private const val LIKED_SCORE = 10
private const val DISLIKED_SCORE = 2
private const val ONBOARDING_LIKED_NOTE = "Onboarding liked"
private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked"
}
}
@@ -0,0 +1,674 @@
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.FilmRatingRepositoryPort
import com.project.movienight.application.ports.output.FilmRepositoryPort
import com.project.movienight.application.ports.output.IdGenerator
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
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.Film
import com.project.movienight.domain.model.FilmLibrary
import com.project.movienight.domain.model.FilmRating
import com.project.movienight.domain.model.RecommendationEvent
import com.project.movienight.domain.model.RecommendationEventType
import com.project.movienight.domain.model.RecommendationResult
import com.project.movienight.domain.model.UserPreferences
import com.project.movienight.domain.model.UserRecommendationWeights
import org.slf4j.LoggerFactory
import org.springframework.stereotype.Service
import java.time.LocalDateTime
import java.util.Locale
import java.util.UUID
import kotlin.math.sqrt
@Service
class RecommendationService(
private val filmRepository: FilmRepositoryPort,
private val filmLibraryRepository: FilmLibraryRepositoryPort,
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,
) : GetRecommendationsUseCase,
AcceptRecommendationUseCase,
RejectRecommendationUseCase {
private val log = LoggerFactory.getLogger(javaClass)
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
businessMetricsService.recordRecommendationRequest()
userRepository.findById(query.userId)
?: throw EntityNotFoundException(entity = "User", id = query.userId.toString())
val preferences = userPreferencesRepository.findByUserId(query.userId)
val ratings = filmRatingRepository.findByUserId(query.userId)
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
val films = filmRepository.findAll()
val filmsById = films.associateBy { it.id }
val weights = findWeights(query.userId)
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights)
val candidates =
films
.asSequence()
.filter { film -> query.contentType == null || film.contentType == query.contentType }
.filter { film -> film.id !in watchedFilmIds }
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
.toList()
val scoredCandidates =
candidates.map { film ->
scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights)
}
val recommendationComparator =
compareByDescending<ScoredRecommendation> { it.result.score }.thenBy {
it.result.film.title
}
val scoredRecommendations =
scoredCandidates
.sortedWith(recommendationComparator)
.take(query.limit.coerceAtLeast(1))
scoredRecommendations.forEach { recommendation ->
saveEvent(
userId = query.userId,
filmId = recommendation.result.film.id,
eventType = RecommendationEventType.RECOMMENDED,
score = recommendation.result.score,
relevanceScore = recommendation.relevanceScore,
qualityScore = recommendation.qualityScore,
contextScore = recommendation.contextScore,
noveltyScore = recommendation.noveltyScore,
diversityScore = recommendation.diversityScore,
)
}
log.info(
RECOMMENDATION_COMPLETED_LOG,
query.userId,
query.contentType,
!query.mood.isNullOrBlank(),
query.libraryOnly,
query.limit,
candidates.size,
scoredRecommendations.size,
)
if (log.isDebugEnabled) {
log.debug(
"Recommendation top results: userId='{}', results='{}'",
query.userId,
scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" },
)
}
return scoredRecommendations.map { it.result }
}
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
saveFeedbackEvent(
userId = command.userId,
filmId = command.filmId,
eventType = RecommendationEventType.ACCEPTED,
)
override fun reject(command: RejectRecommendationCommand): RecommendationEvent =
saveFeedbackEvent(
userId = command.userId,
filmId = command.filmId,
eventType = RecommendationEventType.REJECTED,
)
private fun saveFeedbackEvent(
userId: UUID,
filmId: UUID,
eventType: RecommendationEventType,
): RecommendationEvent {
userRepository.findById(userId)
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
filmRepository.findById(filmId)
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId)
val event =
saveEvent(
userId = userId,
filmId = filmId,
eventType = eventType,
score = lastRecommendation?.score,
relevanceScore = lastRecommendation?.relevanceScore,
qualityScore = lastRecommendation?.qualityScore,
contextScore = lastRecommendation?.contextScore,
noveltyScore = lastRecommendation?.noveltyScore,
diversityScore = lastRecommendation?.diversityScore,
)
if (lastRecommendation != null) {
updateRecommendationWeights(
userId = userId,
eventType = eventType,
recommendation = lastRecommendation,
)
} else {
log.info(
"Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'",
userId,
filmId,
eventType,
)
}
log.info(
RECOMMENDATION_FEEDBACK_SAVED_LOG,
userId,
filmId,
eventType,
)
return event
}
private fun saveEvent(
userId: UUID,
filmId: UUID,
eventType: RecommendationEventType,
score: Double?,
relevanceScore: Double? = null,
qualityScore: Double? = null,
contextScore: Double? = null,
noveltyScore: Double? = null,
diversityScore: Double? = null,
): RecommendationEvent =
recommendationEventRepository.save(
RecommendationEvent(
id = idGenerator.generateId(),
userId = userId,
filmId = filmId,
eventType = eventType,
score = score,
relevanceScore = relevanceScore,
qualityScore = qualityScore,
contextScore = contextScore,
noveltyScore = noveltyScore,
diversityScore = diversityScore,
createdAt = LocalDateTime.now(),
),
)
private fun findWeights(userId: UUID): UserRecommendationWeights =
(
userRecommendationWeightsRepository.findByUserId(userId)
?: UserRecommendationWeights.defaultFor(userId)
).normalized()
private fun updateRecommendationWeights(
userId: UUID,
eventType: RecommendationEventType,
recommendation: RecommendationEvent,
) {
val current = findWeights(userId)
val contributions = scoreContributions(recommendation, current) ?: return
val direction =
when (eventType) {
RecommendationEventType.ACCEPTED -> 1.0
RecommendationEventType.REJECTED -> -1.0
RecommendationEventType.RECOMMENDED -> return
}
val updated =
current
.copy(
relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance,
qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality,
contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context,
noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty,
diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity,
).normalized(updatedAt = LocalDateTime.now())
val saved = userRecommendationWeightsRepository.save(updated)
businessMetricsService.recordRecommendationWeightsUpdated(eventType)
log.info(
RECOMMENDATION_WEIGHTS_UPDATED_LOG,
userId,
eventType,
current.hashCode(),
saved.hashCode(),
)
}
private fun scoreContributions(
recommendation: RecommendationEvent,
weights: UserRecommendationWeights,
): ScoreContributions? {
val rawContributions =
listOf(
weights.relevanceWeight to recommendation.relevanceScore,
weights.qualityWeight to recommendation.qualityScore,
weights.contextWeight to recommendation.contextScore,
weights.noveltyWeight to recommendation.noveltyScore,
weights.diversityWeight to recommendation.diversityScore,
).map { (weight, score) ->
weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0)
}
val total = rawContributions.sum()
if (total <= 0.0) {
return null
}
return ScoreContributions(
relevance = rawContributions[0] / total,
quality = rawContributions[1] / total,
context = rawContributions[2] / total,
novelty = rawContributions[3] / total,
diversity = rawContributions[4] / total,
)
}
private fun buildUserProfile(
preferences: UserPreferences?,
ratings: List<FilmRating>,
libraryEntries: List<FilmLibrary>,
filmsById: Map<UUID, Film>,
weights: UserRecommendationWeights,
): SparseVector {
val profile = MutableSparseVector()
preferences?.weightedGenres.orEmpty().forEach { (genre, weight) ->
profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT)
}
preferences?.plotTypes.orEmpty().forEach { plotType ->
tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) }
}
preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) }
preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) }
preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) }
preferences
?.contentTypes
.orEmpty()
.forEach {
profile.add(
feature("type", it.name),
PREFERENCE_CONTENT_TYPE_WEIGHT,
)
}
ratings.forEach { rating ->
val film = filmsById[rating.filmId] ?: return@forEach
val signal = ratingSignal(rating.score)
profile.add(buildFilmVector(film, weights).scale(signal))
}
libraryEntries.filterNot { it.isViewed }.forEach { entry ->
val film = filmsById[entry.filmId] ?: return@forEach
profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT))
}
return profile.toSparseVector()
}
private fun scoreFilm(
film: Film,
query: RecommendationQuery,
preferences: UserPreferences?,
userProfile: SparseVector,
inLibrary: Boolean,
weights: UserRecommendationWeights,
): ScoredRecommendation {
val reasons = mutableListOf<String>()
val filmVector = buildFilmVector(film, weights)
val preferenceScore = cosineSimilarity(userProfile, filmVector)
val qualityScore = qualityScore(film)
val contextScore = contextScore(film, query, preferences)
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
val diversityScore = diversityScore(film, preferences)
val score =
weights.relevanceWeight * preferenceScore +
weights.qualityWeight * qualityScore +
weights.contextWeight * contextScore +
weights.noveltyWeight * noveltyScore +
weights.diversityWeight * diversityScore
if (preferenceScore > STRONG_REASON_THRESHOLD) {
reasons += "Similar to user preferences and rating history"
}
matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre ->
reasons += "Matches preferred genre: $genre"
}
matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person ->
reasons += "Matches preferred cast or director: $person"
}
query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood ->
reasons += "Matches requested mood: $mood"
}
film.releaseYear?.let { year ->
if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) {
reasons += "Matches preferred era: ${decadeOf(year)}"
}
}
if (qualityScore >= QUALITY_REASON_THRESHOLD) {
reasons += "High rating signal"
}
if (inLibrary) {
reasons += "Already in user library"
}
if (reasons.isEmpty()) {
reasons += "Baseline recommendation from catalog quality"
}
return ScoredRecommendation(
result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()),
relevanceScore = preferenceScore,
qualityScore = qualityScore,
contextScore = contextScore,
noveltyScore = noveltyScore,
diversityScore = diversityScore,
)
}
private fun buildFilmVector(
film: Film,
weights: UserRecommendationWeights,
): SparseVector {
val vector = MutableSparseVector()
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
val plotTokens = tokenize("${film.title} ${film.description}")
val moods = inferredMoods(film)
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight)
distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight)
distribute(vector, "plot", plotTokens, weights.plotVectorWeight)
distribute(vector, "mood", moods, weights.moodVectorWeight)
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
distribute(vector, "person", people, weights.peopleVectorWeight)
return vector.toSparseVector()
}
private fun contextScore(
film: Film,
query: RecommendationQuery,
preferences: UserPreferences?,
): Double {
var score = 0.0
var checks = 0
query.mood?.let {
checks += 1
if (inferredMoods(film).contains(normalize(it))) {
score += 1.0
}
}
preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let {
checks += 1
if (film.contentType in it) {
score += 1.0
}
}
preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras ->
film.releaseYear?.let {
checks += 1
if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) {
score += 1.0
}
}
}
return if (checks == 0) BASE_CONTEXT_SCORE else score / checks
}
private fun qualityScore(film: Film): Double {
val normalizedRatings =
listOfNotNull(
film.imdbRating?.let { normalizeRating(it) },
film.platformRating?.let { normalizeRating(it) },
)
return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE
}
private fun diversityScore(
film: Film,
preferences: UserPreferences?,
): Double {
val preferredGenres =
preferences
?.weightedGenres
.orEmpty()
.keys
.map(::normalize)
.toSet()
val filmGenres = film.genres.map(::normalize).toSet()
return when {
preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE
filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE
filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE
else -> LOW_DIVERSITY_SCORE
}
}
private fun inferredMoods(film: Film): Set<String> {
val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}")
return moodLexicon
.filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } }
.keys
}
private fun matchingGenres(
film: Film,
preferences: UserPreferences?,
): List<String> {
val filmGenres = film.genres.associateBy { normalize(it) }
return preferences
?.weightedGenres
.orEmpty()
.keys
.map(::normalize)
.mapNotNull { filmGenres[it] }
}
private fun matchingPeople(
film: Film,
preferences: UserPreferences?,
): List<String> {
val people = (film.cast + film.directors).associateBy { normalize(it) }
return preferences
?.castAndDirectors
.orEmpty()
.map(::normalize)
.mapNotNull { people[it] }
}
private fun distribute(
vector: MutableSparseVector,
namespace: String,
values: Collection<String>,
totalWeight: Double,
) {
val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct()
if (uniqueValues.isEmpty()) {
return
}
val itemWeight = totalWeight / uniqueValues.size
uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) }
}
private fun ratingSignal(score: Int): Double =
when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) {
10 -> 1.0
9 -> 0.9
8 -> 0.7
7 -> 0.4
6 -> 0.1
5 -> 0.0
4 -> -0.3
3 -> -0.5
2 -> -0.8
else -> -1.0
}
private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0)
private fun decadeOf(year: Int): String = "${year / 10 * 10}s"
private fun tokenize(text: String): List<String> =
normalize(text)
.split(tokenSeparatorRegex)
.asSequence()
.filter { it.length >= MIN_TOKEN_LENGTH }
.filterNot { it in stopWords }
.distinct()
.toList()
private fun feature(
namespace: String,
value: String,
): String = "$namespace:${normalize(value)}"
private fun normalize(value: String): String =
value
.trim()
.lowercase(Locale.getDefault())
private fun cosineSimilarity(
left: SparseVector,
right: SparseVector,
): Double {
if (left.values.isEmpty() || right.values.isEmpty()) {
return 0.0
}
val dot =
left.values
.entries
.sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) }
val leftNorm = sqrt(left.values.values.sumOf { it * it })
val rightNorm = sqrt(right.values.values.sumOf { it * it })
if (leftNorm == 0.0 || rightNorm == 0.0) {
return 0.0
}
return dot / (leftNorm * rightNorm)
}
private fun roundScore(score: Double): Double =
kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR
private fun Iterable<Double>.averageOrNull(): Double? {
val values = toList()
return values.takeIf { it.isNotEmpty() }?.average()
}
private data class ScoredRecommendation(
val result: RecommendationResult,
val relevanceScore: Double,
val qualityScore: Double,
val contextScore: Double,
val noveltyScore: Double,
val diversityScore: Double,
)
private data class ScoreContributions(
val relevance: Double,
val quality: Double,
val context: Double,
val novelty: Double,
val diversity: Double,
)
private data class SparseVector(
val values: Map<String, Double>,
) {
fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight })
}
private class MutableSparseVector {
private val values = mutableMapOf<String, Double>()
fun add(
feature: String,
weight: Double,
) {
if (weight == 0.0) {
return
}
values[feature] = (values[feature] ?: 0.0) + weight
}
fun add(vector: SparseVector) {
vector.values.forEach { (feature, weight) -> add(feature, weight) }
}
fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 })
}
private companion object {
private const val RECOMMENDATION_COMPLETED_LOG =
"Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " +
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
"Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'"
private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG =
"Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}"
private const val MAX_PREFERENCE_WEIGHT = 5.0
private const val MAX_RATING_VALUE = 10.0
private const val MIN_USER_RATING = 1
private const val MAX_USER_RATING = 10
private const val MIN_TOKEN_LENGTH = 3
private const val MAX_REASON_ITEMS = 2
private const val SCORE_ROUNDING_FACTOR = 1000.0
private const val PREFERENCE_PLOT_WEIGHT = 0.6
private const val PREFERENCE_ERA_WEIGHT = 0.7
private const val PREFERENCE_PERSON_WEIGHT = 0.8
private const val PREFERENCE_MOOD_WEIGHT = 0.8
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
private const val LEARNING_RATE = 0.03
private const val LIBRARY_NOVELTY_SCORE = 0.85
private const val CATALOG_NOVELTY_SCORE = 0.65
private const val BASE_CONTEXT_SCORE = 0.5
private const val BASE_QUALITY_SCORE = 0.5
private const val BASE_DIVERSITY_SCORE = 0.5
private const val HIGH_DIVERSITY_SCORE = 1.0
private const val MEDIUM_DIVERSITY_SCORE = 0.6
private const val LOW_DIVERSITY_SCORE = 0.3
private const val STRONG_REASON_THRESHOLD = 0.15
private const val QUALITY_REASON_THRESHOLD = 0.75
private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+")
private val stopWords =
setOf(
"and",
"the",
"for",
"with",
"about",
"into",
"from",
)
private val moodLexicon =
mapOf(
"tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"),
"slow-burn" to listOf("slow", "meditative", "grounded"),
"feel-good" to listOf("comedy", "family", "summer", "kind", "warm"),
"dark" to listOf("dark", "noir", "horror", "murder", "crime"),
"romantic" to listOf("romance", "love", "relationship"),
"focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"),
)
}
}
@@ -0,0 +1,29 @@
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.output.UserPreferencesRepositoryPort
import com.project.movienight.domain.model.UserPreferences
import org.springframework.stereotype.Service
@Service
class UserPreferencesService(
private val userPreferencesRepository: UserPreferencesRepositoryPort,
) : UpsertUserPreferencesUseCase,
GetUserPreferencesUseCase {
override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences =
userPreferencesRepository.save(
UserPreferences(
userId = command.userId,
weightedGenres = command.weightedGenres,
plotTypes = command.plotTypes,
eras = command.eras,
castAndDirectors = command.castAndDirectors,
moods = command.moods,
contentTypes = command.contentTypes,
),
)
override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId)
}
@@ -0,0 +1,51 @@
package com.project.movienight.application.services
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
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.UserRecommendationWeights
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class UserRecommendationWeightsService(
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
private val userRepository: UserRepositoryPort,
) : GetUserRecommendationWeightsUseCase,
UpdateUserRecommendationWeightsUseCase {
override fun get(userId: UUID): UserRecommendationWeights {
ensureUserExists(userId)
return (
userRecommendationWeightsRepository.findByUserId(userId)
?: UserRecommendationWeights.defaultFor(userId)
).normalized()
}
override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights {
ensureUserExists(command.userId)
return userRecommendationWeightsRepository.save(
UserRecommendationWeights(
userId = command.userId,
relevanceWeight = command.relevanceWeight,
qualityWeight = command.qualityWeight,
contextWeight = command.contextWeight,
noveltyWeight = command.noveltyWeight,
diversityWeight = command.diversityWeight,
genreVectorWeight = command.genreVectorWeight,
plotVectorWeight = command.plotVectorWeight,
moodVectorWeight = command.moodVectorWeight,
eraVectorWeight = command.eraVectorWeight,
peopleVectorWeight = command.peopleVectorWeight,
contentTypeVectorWeight = command.contentTypeVectorWeight,
),
)
}
private fun ensureUserExists(userId: UUID) {
userRepository.findById(userId)
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
}
}
@@ -5,6 +5,8 @@ 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.output.IdGenerator
import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.UserServiceProperties
@@ -21,7 +23,9 @@ class UserService(
private val userConfig: UserServiceProperties,
) : CreateUserUseCase,
EditUserUseCase,
DeleteUserUseCase {
DeleteUserUseCase,
GetUserByIdUseCase,
GetAllUsersUseCase {
override fun create(command: CreateUserCommand): User {
if (userConfig.isBlocked(command.name)) {
throw BlockedValueException(target = "User", field = "name")
@@ -33,6 +37,7 @@ class UserService(
name = command.name,
email = command.email,
library = null,
jellyfinUserId = null,
)
return userRepository.save(user)
}
@@ -47,14 +52,22 @@ class UserService(
var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
user = user.copy(name = command.name)
user =
user.copy(
name = command.name,
jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId,
)
return userRepository.save(user)
}
override fun delete(id: UUID) {
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
userRepository.deleteById(id)
}
override fun getById(id: UUID): User =
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
override fun getAll(): List<User> = userRepository.findAll()
}
@@ -0,0 +1,14 @@
package com.project.movienight.config
import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "integrations.jellyfin")
data class JellyfinIntegrationProperties(
val enabled: Boolean = false,
val baseUrl: String = "",
val webUrl: String = "",
val apiKey: String = "",
val syncIntervalMs: Long = 1_800_000,
val requestTimeoutMs: Long = 20_000,
val pluginToken: String = "",
)
@@ -6,4 +6,21 @@ data class Film(
val id: UUID,
val title: String,
val description: String,
val contentType: ContentType = ContentType.FILM,
val releaseYear: Int? = null,
val genres: List<String> = emptyList(),
val cast: List<String> = emptyList(),
val directors: List<String> = emptyList(),
val imdbRating: Double? = null,
val platformRating: Double? = null,
val externalUrl: String? = null,
val jellyfinItemId: String? = null,
val jellyfinLibraryId: String? = null,
)
enum class ContentType {
FILM,
SERIES,
EPISODE,
OTHER,
}
@@ -1,5 +1,6 @@
package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class FilmLibrary(
@@ -8,4 +9,5 @@ data class FilmLibrary(
val filmId: UUID,
val comment: String?,
val isViewed: Boolean,
val watchedAt: LocalDateTime? = null,
)
@@ -0,0 +1,14 @@
package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class FilmRating(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val score: Int,
val note: String? = null,
val createdAt: LocalDateTime = LocalDateTime.now(),
val updatedAt: LocalDateTime = createdAt,
)
@@ -0,0 +1,19 @@
package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class JellyfinSyncState(
val userId: UUID,
val lastSyncedAt: LocalDateTime? = null,
val lastSuccessfulSyncAt: LocalDateTime? = null,
val lastError: String? = null,
val syncedItemCount: Int = 0,
)
data class JellyfinSyncSummary(
val syncedUsers: Int,
val skippedUsers: Int,
val syncedItems: Int,
val durationMs: Long,
)
@@ -0,0 +1,17 @@
package com.project.movienight.domain.model
import java.util.UUID
data class RecommendationContext(
val userId: UUID,
val contentType: ContentType? = null,
val mood: String? = null,
val libraryOnly: Boolean = false,
val limit: Int = 10,
)
data class RecommendationResult(
val film: Film,
val score: Double,
val reasons: List<String>,
)
@@ -0,0 +1,24 @@
package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class RecommendationEvent(
val id: UUID,
val userId: UUID,
val filmId: UUID,
val eventType: RecommendationEventType,
val score: Double? = null,
val relevanceScore: Double? = null,
val qualityScore: Double? = null,
val contextScore: Double? = null,
val noveltyScore: Double? = null,
val diversityScore: Double? = null,
val createdAt: LocalDateTime = LocalDateTime.now(),
)
enum class RecommendationEventType {
RECOMMENDED,
ACCEPTED,
REJECTED,
}
@@ -0,0 +1,9 @@
package com.project.movienight.domain.model
enum class RecommendationStyle {
BALANCED,
QUALITY_FIRST,
MOOD_FIRST,
DISCOVERY,
SIMILAR_TO_FAVORITES,
}
@@ -7,4 +7,6 @@ data class User(
val name: String,
val email: String,
val library: FilmLibrary?,
val preferences: UserPreferences? = null,
val jellyfinUserId: String? = null,
)
@@ -0,0 +1,13 @@
package com.project.movienight.domain.model
import java.util.UUID
data class UserPreferences(
val userId: UUID,
val weightedGenres: Map<String, Int> = emptyMap(),
val plotTypes: List<String> = emptyList(),
val eras: List<String> = emptyList(),
val castAndDirectors: List<String> = emptyList(),
val moods: List<String> = emptyList(),
val contentTypes: List<ContentType> = emptyList(),
)
@@ -0,0 +1,233 @@
package com.project.movienight.domain.model
import java.time.LocalDateTime
import java.util.UUID
data class UserRecommendationWeights(
val userId: UUID,
val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT,
val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT,
val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT,
val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT,
val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT,
val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT,
val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT,
val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT,
val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT,
val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT,
val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
val updatedAt: LocalDateTime = LocalDateTime.now(),
) {
fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights {
val scoreWeights =
normalizeBounded(
values =
listOf(
relevanceWeight,
qualityWeight,
contextWeight,
noveltyWeight,
diversityWeight,
),
defaults = DEFAULT_SCORE_WEIGHTS,
min = MIN_SCORE_WEIGHT,
max = MAX_SCORE_WEIGHT,
)
val vectorWeights =
normalizeBounded(
values =
listOf(
genreVectorWeight,
plotVectorWeight,
moodVectorWeight,
eraVectorWeight,
peopleVectorWeight,
contentTypeVectorWeight,
),
defaults = DEFAULT_VECTOR_WEIGHTS,
min = MIN_VECTOR_WEIGHT,
max = MAX_VECTOR_WEIGHT,
)
return copy(
relevanceWeight = scoreWeights[0],
qualityWeight = scoreWeights[1],
contextWeight = scoreWeights[2],
noveltyWeight = scoreWeights[3],
diversityWeight = scoreWeights[4],
genreVectorWeight = vectorWeights[0],
plotVectorWeight = vectorWeights[1],
moodVectorWeight = vectorWeights[2],
eraVectorWeight = vectorWeights[3],
peopleVectorWeight = vectorWeights[4],
contentTypeVectorWeight = vectorWeights[5],
updatedAt = updatedAt,
)
}
companion object {
const val DEFAULT_RELEVANCE_WEIGHT = 0.55
const val DEFAULT_QUALITY_WEIGHT = 0.15
const val DEFAULT_CONTEXT_WEIGHT = 0.10
const val DEFAULT_NOVELTY_WEIGHT = 0.10
const val DEFAULT_DIVERSITY_WEIGHT = 0.10
const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25
const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35
const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15
const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10
const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10
const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05
const val MIN_SCORE_WEIGHT = 0.05
const val MAX_SCORE_WEIGHT = 0.75
const val MIN_VECTOR_WEIGHT = 0.03
const val MAX_VECTOR_WEIGHT = 0.60
private val DEFAULT_SCORE_WEIGHTS =
listOf(
DEFAULT_RELEVANCE_WEIGHT,
DEFAULT_QUALITY_WEIGHT,
DEFAULT_CONTEXT_WEIGHT,
DEFAULT_NOVELTY_WEIGHT,
DEFAULT_DIVERSITY_WEIGHT,
)
private val DEFAULT_VECTOR_WEIGHTS =
listOf(
DEFAULT_GENRE_VECTOR_WEIGHT,
DEFAULT_PLOT_VECTOR_WEIGHT,
DEFAULT_MOOD_VECTOR_WEIGHT,
DEFAULT_ERA_VECTOR_WEIGHT,
DEFAULT_PEOPLE_VECTOR_WEIGHT,
DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
)
fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId)
fun forStyle(
userId: UUID,
style: RecommendationStyle,
): UserRecommendationWeights =
when (style) {
RecommendationStyle.BALANCED -> {
defaultFor(userId)
}
RecommendationStyle.QUALITY_FIRST -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.40,
qualityWeight = 0.35,
contextWeight = 0.10,
noveltyWeight = 0.05,
diversityWeight = 0.10,
)
}
RecommendationStyle.MOOD_FIRST -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.45,
qualityWeight = 0.10,
contextWeight = 0.25,
noveltyWeight = 0.10,
diversityWeight = 0.10,
moodVectorWeight = 0.30,
)
}
RecommendationStyle.DISCOVERY -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.30,
qualityWeight = 0.10,
contextWeight = 0.10,
noveltyWeight = 0.25,
diversityWeight = 0.25,
)
}
RecommendationStyle.SIMILAR_TO_FAVORITES -> {
UserRecommendationWeights(
userId = userId,
relevanceWeight = 0.70,
qualityWeight = 0.10,
contextWeight = 0.10,
noveltyWeight = 0.05,
diversityWeight = 0.05,
genreVectorWeight = 0.30,
plotVectorWeight = 0.40,
peopleVectorWeight = 0.15,
)
}
}.normalized()
private fun normalizeBounded(
values: List<Double>,
defaults: List<Double>,
min: Double,
max: Double,
): List<Double> {
val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 }
val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults
val normalized = source.map { it / source.sum() }
return projectToBounds(normalized, min, max)
}
private fun projectToBounds(
values: List<Double>,
min: Double,
max: Double,
): List<Double> {
val result = values.map { it.coerceIn(min, max) }.toMutableList()
var iterations = 0
var adjusting = true
while (iterations < values.size * 2 && adjusting) {
iterations += 1
val diff = 1.0 - result.sum()
if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) {
adjusting = false
} else {
adjusting = redistribute(result, diff, min, max)
}
}
return result
}
private fun redistribute(
result: MutableList<Double>,
diff: Double,
min: Double,
max: Double,
): Boolean =
if (diff > 0.0) {
val candidates = result.indices.filter { result[it] < max }
val capacity = candidates.sumOf { max - result[it] }
if (capacity > 0.0) {
candidates.forEach { index ->
val increment = diff * ((max - result[index]) / capacity)
result[index] = (result[index] + increment).coerceAtMost(max)
}
true
} else {
false
}
} else {
val candidates = result.indices.filter { result[it] > min }
val capacity = candidates.sumOf { result[it] - min }
if (capacity > 0.0) {
candidates.forEach { index ->
val decrement = -diff * ((result[index] - min) / capacity)
result[index] = (result[index] - decrement).coerceAtLeast(min)
}
true
} else {
false
}
}
private const val NORMALIZATION_EPSILON = 0.0000001
}
}