Merge pull request #52 from devitq/fix-ui-components-jellyfin-10-11-8294678353369689429

Refactor Plugin UI for Jellyfin 10.11+
This commit was merged in pull request #52.
This commit is contained in:
ITQ
2026-05-22 16:36:01 +03:00
committed by GitHub
94 changed files with 4939 additions and 158 deletions
@@ -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');
}
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")]
[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
}
}