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 + } +}