diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
index 9efbad4..e02f661 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js
@@ -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 = `${text}`;
+ btn.onclick = onClick;
+ return btn;
+ }
- if (headerButtons) {
- if (!document.querySelector('.btnMovieNightRecommend')) {
- const btn = document.createElement('button');
- btn.className = 'emby-button raised btnMovieNightRecommend';
- btn.innerHTML = 'Recommend Film';
- 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 = `
+
+
+
+ `;
+ btn.onclick = onClick;
+ return btn;
+ }
- if (!document.querySelector('.btnMovieNightAddMovie')) {
- const btn = document.createElement('button');
- btn.className = 'emby-button raised btnMovieNightAddMovie';
- btn.innerHTML = 'Add Movie (STRM)';
- 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 = `
+
+
MovieNight
+
+
+
+ `;
+ const btnContainer = section.querySelector('.movieNightBtnContainer');
+ btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
+ btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog));
+ btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync));
- 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 = `
+ ${title}
+
+
+ `;
+ return dialog;
+ }
+
+ async function showRatingDialog(itemId) {
+ const overlay = createOverlay();
+ const dialog = createDialogBase('Rate on MovieNight');
+ const content = dialog.querySelector('.dialog-content');
+
+ content.innerHTML = ``;
+ const grid = content.querySelector('.rating-grid');
+
+ const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
+
+ for (let i = 1; i <= 10; i++) {
+ const btn = document.createElement('button');
+ btn.type = 'button'; btn.is = 'emby-button';
+ btn.className = 'emby-button raised';
+ btn.innerText = i;
+ btn.style.padding = '0.8em 0';
+ btn.style.textAlign = 'center';
+ btn.style.display = 'flex';
+ btn.style.alignItems = 'center';
+ btn.style.justifyContent = 'center';
+ btn.style.fontSize = '1.2em';
+ btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
+ grid.appendChild(btn);
+ }
+
+ dialog.querySelector('.btnCancel').onclick = cleanup;
+ overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
+ overlay.appendChild(dialog);
+ document.body.appendChild(overlay);
+ }
+
+ async function showAddMovieDialog() {
+ const overlay = createOverlay();
+ const dialog = createDialogBase('Add Movie (STRM)');
+ const content = dialog.querySelector('.dialog-content');
+ const footer = dialog.querySelector('.dialog-footer');
+
+ content.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ const btnAdd = document.createElement('button');
+ btnAdd.className = 'emby-button raised button-submit';
+ btnAdd.style.flex = '2';
+ btnAdd.style.backgroundColor = '#0064d2';
+ btnAdd.innerHTML = 'Add Film';
+ footer.insertBefore(btnAdd, footer.firstChild);
+
+ const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
+
+ btnAdd.onclick = async () => {
+ const title = dialog.querySelector('.txtTitle').value;
+ const year = dialog.querySelector('.txtYear').value;
+ const imdbId = dialog.querySelector('.txtImdb').value;
+ const url = dialog.querySelector('.txtUrl').value;
+ if (!title) return;
+ cleanup();
+ await addMovie(title, url, year, imdbId);
+ };
+
+ dialog.querySelector('.btnCancel').onclick = cleanup;
+ overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
+ overlay.appendChild(dialog);
+ document.body.appendChild(overlay);
+ dialog.querySelector('.txtTitle').focus();
+ }
+
+ async function showOnboardingDialog() {
+ const overlay = createOverlay();
+ const dialog = createDialogBase('Welcome to MovieNight!');
+ dialog.style.minWidth = '450px';
+ const content = dialog.querySelector('.dialog-content');
+ const footer = dialog.querySelector('.dialog-footer');
+
+ content.innerHTML = `
+ Pick your preferences to get better recommendations.
+
+
+
+ `;
+
+ const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"];
+ const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"];
+ const types = ["FILM", "SERIES"];
+
+ const selections = { genres: new Set(), eras: new Set(), types: new Set() };
+
+ const createChip = (text, container, type) => {
+ const chip = document.createElement('div');
+ chip.innerText = text;
+ chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;';
+ chip.onclick = () => {
+ if (selections[type].has(text)) {
+ selections[type].delete(text);
+ chip.style.backgroundColor = 'transparent';
+ chip.style.borderColor = '#444';
+ } else {
+ selections[type].add(text);
+ chip.style.backgroundColor = '#0064d2';
+ chip.style.borderColor = '#0064d2';
+ }
+ };
+ container.appendChild(chip);
+ };
+
+ genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres'));
+ eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras'));
+ types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types'));
+
+ const btnSave = document.createElement('button');
+ btnSave.className = 'emby-button raised button-submit';
+ btnSave.style.flex = '2';
+ btnSave.style.backgroundColor = '#0064d2';
+ btnSave.innerHTML = 'Save & Start';
+ footer.insertBefore(btnSave, footer.firstChild);
+
+ const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
+
+ btnSave.onclick = async () => {
+ const payload = {
+ weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])),
+ eras: [...selections.eras],
+ contentTypes: [...selections.types]
+ };
+ cleanup();
+ await completeOnboarding(payload);
+ };
+
+ dialog.querySelector('.btnCancel').onclick = cleanup;
+ overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
+ overlay.appendChild(dialog);
+ document.body.appendChild(overlay);
+ }
+
+ async function checkOnboarding() {
+ if (window.movieNightOnboardingChecked) return;
+ window.movieNightOnboardingChecked = true;
+
+ const userId = ApiClient.getCurrentUserId();
+ if (!userId) return;
+
+ try {
+ const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`));
+ if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) {
+ showOnboardingDialog();
+ }
+ } catch (err) {
+ if (err.status === 404) showOnboardingDialog();
+ }
+ }
+
+ async function completeOnboarding(payload) {
+ const userId = ApiClient.getCurrentUserId();
+ try {
+ await ApiClient.ajax({
+ type: 'POST',
+ url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`),
+ data: JSON.stringify(payload),
+ contentType: 'application/json'
+ });
+ showMsg('Welcome! Your preferences have been saved.');
+ } catch (err) {
+ showMsg('Failed to save onboarding preferences.');
+ }
+ }
+
async function showRecommendation() {
const userId = ApiClient.getCurrentUserId();
try {
@@ -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();
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
index b5509cb..8c9ff73 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs
@@ -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.
///
[ApiController]
-[Authorize]
[Route("MovieNight")]
public class MovieNightController : ControllerBase
{
@@ -22,17 +24,26 @@ public class MovieNightController : ControllerBase
///
/// Initializes a new instance of the class.
///
- public MovieNightController(MovieNightBackendClient backendClient, MovieNightSyncService syncService)
+ public MovieNightController(
+ MovieNightBackendClient backendClient,
+ MovieNightSyncService syncService)
{
_backendClient = backendClient;
_syncService = syncService;
}
+ ///
+ /// Ping endpoint for connectivity checks.
+ ///
+ [HttpGet("Ping")]
+ public ActionResult Ping() => Ok("Pong");
+
///
/// Returns plugin status.
///
/// Status response.
[HttpGet("Status")]
+ [Authorize]
public ActionResult GetStatus()
{
var configuration = Plugin.Instance?.Configuration;
@@ -50,6 +61,7 @@ public class MovieNightController : ControllerBase
/// Cancellation token.
/// Connection result.
[HttpPost("TestConnection")]
+ [Authorize]
public async Task> TestConnection(CancellationToken cancellationToken)
{
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
@@ -61,6 +73,7 @@ public class MovieNightController : ControllerBase
/// Cancellation token.
/// Backend response.
[HttpPost("Sync")]
+ [Authorize]
public async Task> Sync(CancellationToken cancellationToken)
{
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
@@ -73,6 +86,7 @@ public class MovieNightController : ControllerBase
/// Cancellation token.
/// Backend response.
[HttpGet("SyncState")]
+ [Authorize]
public async Task> SyncState(CancellationToken cancellationToken)
{
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
@@ -82,6 +96,7 @@ public class MovieNightController : ControllerBase
/// Gets recommendations for the current user.
///
[HttpGet("Users/{userId}/Recommendations")]
+ [Authorize]
public async Task> GetRecommendations(
[FromRoute] string userId,
[FromQuery] string? contentType,
@@ -96,6 +111,7 @@ public class MovieNightController : ControllerBase
/// Posts a rating for a film.
///
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
+ [Authorize]
public async Task PostRating(
[FromRoute] string userId,
[FromRoute] string filmId,
@@ -110,6 +126,7 @@ public class MovieNightController : ControllerBase
/// Marks a film as viewed.
///
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
+ [Authorize]
public async Task MarkViewed(
[FromRoute] string userId,
[FromRoute] string filmId,
@@ -121,9 +138,37 @@ public class MovieNightController : ControllerBase
}
///
- /// Creates a new film by generating a .strm file.
+ /// Gets user preferences.
+ ///
+ [HttpGet("Users/{userId}/Preferences")]
+ [Authorize]
+ public async Task> GetPreferences(
+ [FromRoute] string userId,
+ CancellationToken cancellationToken)
+ {
+ return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Completes onboarding for a user.
+ ///
+ [HttpPost("Users/{userId}/Onboarding")]
+ [Authorize]
+ public async Task CompleteOnboarding(
+ [FromRoute] string userId,
+ [FromBody] object payload,
+ CancellationToken cancellationToken)
+ {
+ await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
+ return Ok();
+ }
+
+ ///
+ /// Creates a new film by generating a .strm file in a folder-per-movie structure.
+ /// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
///
[HttpPost("Films")]
+ [Authorize]
public async Task CreateFilm([FromBody] CreateFilmRequest request)
{
var config = Plugin.Instance?.Configuration;
@@ -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
///
/// Create film request.
///
-public sealed record CreateFilmRequest(string Title);
+public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
///
/// Rating request.
diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs
index 58b8e62..deca123 100644
--- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs
+++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs
@@ -167,6 +167,34 @@ public class MovieNightBackendClient
return body;
}
+ ///
+ /// Gets user preferences.
+ ///
+ public async Task 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;
+ }
+
+ ///
+ /// Completes onboarding for a user.
+ ///
+ 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();
+ }
+
///
/// Pushes an event payload to the backend event endpoint.
///
diff --git a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt
index 39274c8..6db897a 100644
--- a/src/main/kotlin/com/project/movienight/MovieNightApplication.kt
+++ b/src/main/kotlin/com/project/movienight/MovieNightApplication.kt
@@ -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
diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt
new file mode 100644
index 0000000..71670be
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt
@@ -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,
+ val cast: List,
+ val directors: List,
+ 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 =
+ 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 =
+ @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 =
+ 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 =
+ takeIf { it.isArray }?.mapNotNull { item ->
+ item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
+ }
+ ?: emptyList()
+
+ private fun JsonNode.peopleByType(vararg types: String): List {
+ 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
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt
new file mode 100644
index 0000000..b3912f1
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt
@@ -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()
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt
new file mode 100644
index 0000000..10a86db
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/FilmRatingEntity.kt
@@ -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,
+ )
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt
new file mode 100644
index 0000000..5edd5c9
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/JellyfinSyncStateEntity.kt
@@ -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,
+ )
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt
index 0beda74..58e2c3c 100644
--- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt
@@ -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,
)
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt
new file mode 100644
index 0000000..706d175
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserPreferencesEntity.kt
@@ -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 }),
+ )
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt
index f5603cb..9fa474d 100644
--- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt
@@ -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 =
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,
)
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt
new file mode 100644
index 0000000..85334d0
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRatingRepository.kt
@@ -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 =
+ 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()
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt
index 2883aca..b1d4ab2 100644
--- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt
@@ -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 =
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,
+ )
}
}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt
new file mode 100644
index 0000000..153eba7
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt
@@ -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)
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt
new file mode 100644
index 0000000..29deb18
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinSyncStateRepository.kt
@@ -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 =
+ 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() }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt
new file mode 100644
index 0000000..ab0f0f8
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/RecommendationEventRepository.kt
@@ -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 =
+ 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()
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt
new file mode 100644
index 0000000..33ec0cc
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserPreferencesRepository.kt
@@ -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()
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt
new file mode 100644
index 0000000..c549925
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRecommendationWeightsRepository.kt
@@ -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
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt
index 6c70b4c..ae34f89 100644
--- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt
@@ -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 =
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,
diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt
new file mode 100644
index 0000000..d671f71
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/support/DelimitedValueCodec.kt
@@ -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 = values.joinToString("|") { encode(it) }
+
+ fun decodeList(value: String?): List =
+ value
+ ?.takeIf { it.isNotBlank() }
+ ?.split("|")
+ ?.map { decode(it) }
+ ?: emptyList()
+
+ fun encodeWeightedMap(values: Map): String =
+ values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
+
+ fun decodeWeightedMap(value: String?): Map {
+ 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)
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt
new file mode 100644
index 0000000..b64ea71
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt
@@ -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())
+ }
+ }
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt
new file mode 100644
index 0000000..123f9a0
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/GoogleOAuth2UserInfo.kt
@@ -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,
+) : 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 = attributes
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt
new file mode 100644
index 0000000..1faf660
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/OAuth2UserInfoFactory.kt
@@ -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")
+ }
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt
new file mode 100644
index 0000000..0bcb1b3
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt
@@ -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()
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt
new file mode 100644
index 0000000..dd8eb93
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/UserPrincipal.kt
@@ -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? = null,
+) : OAuth2User,
+ UserDetails {
+ fun getId(): UUID = user.id
+
+ override fun getName(): String = user.name
+
+ override fun getAttributes(): Map = attributes ?: emptyMap()
+
+ override fun getAuthorities(): Collection =
+ 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? = null,
+ ): UserPrincipal = UserPrincipal(user, attributes)
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt
new file mode 100644
index 0000000..9b55f35
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/VkOAuth2UserInfo.kt
@@ -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,
+) : 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 = attributes
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt
new file mode 100644
index 0000000..dc71df9
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/security/YandexOAuth2UserInfo.kt
@@ -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,
+) : 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 = attributes
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt
index 27a236d..40b3362 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt
@@ -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,
)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt
index d4d52ef..9bec40b 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt
@@ -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 = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
+
+ @GetMapping("/search")
+ fun searchByTitle(
+ @RequestParam title: String,
+ ): ResponseEntity {
+ val film = searchFilmByTitleUseCase.searchByTitle(title)
+ return if (film != null) {
+ ResponseEntity.ok(FilmResponse.fromDomain(film))
+ } else {
+ ResponseEntity.notFound().build()
+ }
+ }
}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt
index 74b04bc..81115cd 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt
@@ -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 = 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 {
+ 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) }
+ }
}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt
new file mode 100644
index 0000000..fec4889
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt
@@ -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 = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt
new file mode 100644
index 0000000..81ee52c
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt
@@ -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,
+ )
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt
new file mode 100644
index 0000000..74aac90
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt
@@ -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 = jellyfinSyncService.getSyncStates()
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt
new file mode 100644
index 0000000..6d5bdd9
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt
@@ -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 =
+ 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"
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt
new file mode 100644
index 0000000..0ff38f6
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationOnboardingController.kt
@@ -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)
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt
new file mode 100644
index 0000000..596cb47
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/TraceIdFilter.kt
@@ -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")
+ }
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt
index f270736..e06954c 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt
@@ -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 = 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,
),
),
)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt
new file mode 100644
index 0000000..276565b
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt
@@ -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) }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt
new file mode 100644
index 0000000..4c5dc1b
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/UserRecommendationWeightsController.kt
@@ -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,
+ ),
+ ),
+ )
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt
index 994429d..82f7348 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt
@@ -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 = emptyList(),
+ val cast: List = emptyList(),
+ val directors: List = emptyList(),
+ val imdbRating: Double? = null,
+ val platformRating: Double? = null,
+ val externalUrl: String? = null,
+ val jellyfinItemId: String? = null,
+ val jellyfinLibraryId: String? = null,
)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt
index 9e476c3..60eddce 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt
@@ -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 = emptyList(),
+ val cast: List = emptyList(),
+ val directors: List = emptyList(),
+ val imdbRating: Double? = null,
+ val platformRating: Double? = null,
+ val externalUrl: String? = null,
+ val jellyfinItemId: String? = null,
+ val jellyfinLibraryId: String? = null,
)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt
index 83ddd24..358e0e4 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt
@@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request
data class EditUserRequest(
val name: String,
+ val jellyfinUserId: String? = null,
)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt
new file mode 100644
index 0000000..68abfe8
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt
@@ -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? = null,
+)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt
new file mode 100644
index 0000000..1f44e39
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt
@@ -0,0 +1,6 @@
+package com.project.movienight.adapters.web.dto.request
+
+data class RateFilmRequest(
+ val score: Int,
+ val note: String? = null,
+)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt
new file mode 100644
index 0000000..0480531
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RecommendationOnboardingRequest.kt
@@ -0,0 +1,17 @@
+package com.project.movienight.adapters.web.dto.request
+
+import java.util.UUID
+
+data class RecommendationOnboardingRequest(
+ val weightedGenres: Map = emptyMap(),
+ val plotTypes: List = emptyList(),
+ val eras: List = emptyList(),
+ val castAndDirectors: List = emptyList(),
+ val moods: List = emptyList(),
+ val contentTypes: List = emptyList(),
+ val likedFilmIds: List = emptyList(),
+ val dislikedFilmIds: List = emptyList(),
+ val libraryFilmIds: List = emptyList(),
+ val watchedFilmIds: List = emptyList(),
+ val recommendationStyle: String = "BALANCED",
+)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt
new file mode 100644
index 0000000..3c0846b
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpdateUserRecommendationWeightsRequest.kt
@@ -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,
+)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt
new file mode 100644
index 0000000..38c809e
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/UpsertUserPreferencesRequest.kt
@@ -0,0 +1,10 @@
+package com.project.movienight.adapters.web.dto.request
+
+data class UpsertUserPreferencesRequest(
+ val weightedGenres: Map = emptyMap(),
+ val plotTypes: List = emptyList(),
+ val eras: List = emptyList(),
+ val castAndDirectors: List = emptyList(),
+ val moods: List = emptyList(),
+ val contentTypes: List = emptyList(),
+)
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt
index 8ba6c01..90a339d 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt
@@ -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,
)
}
}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt
new file mode 100644
index 0000000..8f276fc
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmRatingResponse.kt
@@ -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,
+ )
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt
index 239196d..4948540 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmResponse.kt
@@ -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,
+ val cast: List,
+ val directors: List,
+ 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,
)
}
}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt
new file mode 100644
index 0000000..4fc12ca
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationEventResponse.kt
@@ -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,
+ )
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt
new file mode 100644
index 0000000..6cd2810
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationOnboardingResponse.kt
@@ -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,
+ )
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt
new file mode 100644
index 0000000..da78e72
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/RecommendationResponse.kt
@@ -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,
+ 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),
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt
new file mode 100644
index 0000000..2388d3f
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserPreferencesResponse.kt
@@ -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,
+ val plotTypes: List,
+ val eras: List,
+ val castAndDirectors: List,
+ val moods: List,
+ val contentTypes: List,
+) {
+ 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,
+ )
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt
new file mode 100644
index 0000000..0b22037
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserRecommendationWeightsResponse.kt
@@ -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,
+ )
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt
index 48f5dd8..b1b94f4 100644
--- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt
+++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/UserResponse.kt
@@ -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,
)
}
}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt
index cf9a0b8..3100547 100644
--- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt
@@ -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
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt
new file mode 100644
index 0000000..37c8226
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt
@@ -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
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt
index 3622878..27098c7 100644
--- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt
@@ -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 = emptyList(),
+ val cast: List = emptyList(),
+ val directors: List = 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 = emptyList(),
+ val cast: List = emptyList(),
+ val directors: List = 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
+}
+
+interface SearchFilmByTitleUseCase {
+ fun searchByTitle(title: String): Film?
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt
new file mode 100644
index 0000000..146e3bc
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/GetRecommendationsUseCase.kt
@@ -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
+}
+
+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,
+)
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt
new file mode 100644
index 0000000..0d54caf
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/RecommendationOnboardingUseCase.kt
@@ -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 = emptyMap(),
+ val plotTypes: List = emptyList(),
+ val eras: List = emptyList(),
+ val castAndDirectors: List = emptyList(),
+ val moods: List = emptyList(),
+ val contentTypes: List = emptyList(),
+ val likedFilmIds: List = emptyList(),
+ val dislikedFilmIds: List = emptyList(),
+ val libraryFilmIds: List = emptyList(),
+ val watchedFilmIds: List = 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,
+)
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt
new file mode 100644
index 0000000..b44820c
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt
@@ -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 = emptyMap(),
+ val plotTypes: List = emptyList(),
+ val eras: List = emptyList(),
+ val castAndDirectors: List = emptyList(),
+ val moods: List = emptyList(),
+ val contentTypes: List = emptyList(),
+)
+
+interface GetUserPreferencesUseCase {
+ fun get(userId: UUID): UserPreferences?
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt
new file mode 100644
index 0000000..9bfaa6b
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserRecommendationWeightsUseCase.kt
@@ -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,
+)
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt
index c889946..b066a4f 100644
--- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt
@@ -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
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt
new file mode 100644
index 0000000..e45db2b
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/input/security/OAuth2UserInfo.kt
@@ -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
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt
index a3eb9c9..933f45c 100644
--- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt
@@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort {
fun findById(id: UUID): FilmLibrary?
+ fun findByUserIdAndFilmId(
+ userId: UUID,
+ filmId: UUID,
+ ): FilmLibrary?
+
fun findAll(): List
fun deleteById(id: UUID)
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt
new file mode 100644
index 0000000..908e5ff
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRatingRepositoryPort.kt
@@ -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
+
+ fun findByUserIdAndFilmId(
+ userId: UUID,
+ filmId: UUID,
+ ): FilmRating?
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt
index 91d45b6..d18b2e6 100644
--- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt
@@ -8,7 +8,13 @@ interface FilmRepositoryPort {
fun findById(id: UUID): Film?
+ fun findByJellyfinItemId(jellyfinItemId: String): Film?
+
+ fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
+
fun findAll(): List
+ fun findByTitle(title: String): Film?
+
fun deleteById(id: UUID)
}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt
new file mode 100644
index 0000000..78d75b5
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinSyncStateRepositoryPort.kt
@@ -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
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt
new file mode 100644
index 0000000..5903a4c
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/RecommendationEventRepositoryPort.kt
@@ -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
+
+ fun findLatestRecommended(
+ userId: UUID,
+ filmId: UUID,
+ ): RecommendationEvent?
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt
new file mode 100644
index 0000000..0d110b5
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserPreferencesRepositoryPort.kt
@@ -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?
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt
new file mode 100644
index 0000000..f4bade2
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRecommendationWeightsRepositoryPort.kt
@@ -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
+}
diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt
index e3c902c..dd69728 100644
--- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt
+++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt
@@ -9,6 +9,8 @@ interface UserRepositoryPort {
fun findById(id: UUID): User?
+ fun findByEmail(email: String): User?
+
fun findAll(): List
fun deleteById(id: UUID)
diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt
index ba64e2a..2924922 100644
--- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt
+++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt
@@ -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 = 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 }
}
diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt
new file mode 100644
index 0000000..738bada
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt
@@ -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 = filmRatingRepository.findByUserId(userId)
+}
diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt
index 4166760..d69a6c8 100644
--- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt
+++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt
@@ -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 = 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)
}
diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt
new file mode 100644
index 0000000..64be033
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt
@@ -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?,
+ ) {
+ 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
+ }
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt
new file mode 100644
index 0000000..46faa88
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt
@@ -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 = 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,
+ ),
+ )
+ }
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt
new file mode 100644
index 0000000..8f9fe04
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt
@@ -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) {
+ 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"
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt
new file mode 100644
index 0000000..e8a7722
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt
@@ -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 {
+ 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 { 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,
+ libraryEntries: List,
+ filmsById: Map,
+ 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()
+ 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 {
+ 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 {
+ 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 {
+ 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,
+ 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 =
+ 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.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,
+ ) {
+ fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight })
+ }
+
+ private class MutableSparseVector {
+ private val values = mutableMapOf()
+
+ 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"),
+ )
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt
new file mode 100644
index 0000000..de388ce
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt
@@ -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)
+}
diff --git a/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt
new file mode 100644
index 0000000..fc30d72
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/application/services/UserRecommendationWeightsService.kt
@@ -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())
+ }
+}
diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt
index be32d6a..684da5f 100644
--- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt
+++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt
@@ -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 = userRepository.findAll()
}
diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt
new file mode 100644
index 0000000..a4dc555
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt
@@ -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 = "",
+)
diff --git a/src/main/kotlin/com/project/movienight/domain/model/Film.kt b/src/main/kotlin/com/project/movienight/domain/model/Film.kt
index 32122de..76f2657 100644
--- a/src/main/kotlin/com/project/movienight/domain/model/Film.kt
+++ b/src/main/kotlin/com/project/movienight/domain/model/Film.kt
@@ -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 = emptyList(),
+ val cast: List = emptyList(),
+ val directors: List = 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,
+}
diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt
index 868f57a..8d7861c 100644
--- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt
+++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt
@@ -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,
)
diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt
new file mode 100644
index 0000000..380060d
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/FilmRating.kt
@@ -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,
+)
diff --git a/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt
new file mode 100644
index 0000000..d2395fa
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/JellyfinSyncState.kt
@@ -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,
+)
diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt
new file mode 100644
index 0000000..142754a
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationContext.kt
@@ -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,
+)
diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt
new file mode 100644
index 0000000..3549398
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationEvent.kt
@@ -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,
+}
diff --git a/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt
new file mode 100644
index 0000000..fda5b1d
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/RecommendationStyle.kt
@@ -0,0 +1,9 @@
+package com.project.movienight.domain.model
+
+enum class RecommendationStyle {
+ BALANCED,
+ QUALITY_FIRST,
+ MOOD_FIRST,
+ DISCOVERY,
+ SIMILAR_TO_FAVORITES,
+}
diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt
index b4f2d9b..236a698 100644
--- a/src/main/kotlin/com/project/movienight/domain/model/User.kt
+++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt
@@ -7,4 +7,6 @@ data class User(
val name: String,
val email: String,
val library: FilmLibrary?,
+ val preferences: UserPreferences? = null,
+ val jellyfinUserId: String? = null,
)
diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt
new file mode 100644
index 0000000..451e227
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/UserPreferences.kt
@@ -0,0 +1,13 @@
+package com.project.movienight.domain.model
+
+import java.util.UUID
+
+data class UserPreferences(
+ val userId: UUID,
+ val weightedGenres: Map = emptyMap(),
+ val plotTypes: List = emptyList(),
+ val eras: List = emptyList(),
+ val castAndDirectors: List = emptyList(),
+ val moods: List = emptyList(),
+ val contentTypes: List = emptyList(),
+)
diff --git a/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt
new file mode 100644
index 0000000..ebc8635
--- /dev/null
+++ b/src/main/kotlin/com/project/movienight/domain/model/UserRecommendationWeights.kt
@@ -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,
+ defaults: List,
+ min: Double,
+ max: Double,
+ ): List {
+ 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,
+ min: Double,
+ max: Double,
+ ): List {
+ 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,
+ 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
+ }
+}