Merge pull request #52 from devitq/fix-ui-components-jellyfin-10-11-8294678353369689429
Refactor Plugin UI for Jellyfin 10.11+
This commit was merged in pull request #52.
This commit is contained in:
@@ -13,66 +13,335 @@
|
||||
|
||||
const showMsg = getAlert();
|
||||
|
||||
function injectUI() {
|
||||
const headerButtons = document.querySelector('.headerViewButtons, .view-library .content-primary, .home-section .sectionTitleContainer');
|
||||
function createTextButton(text, className, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.is = 'emby-button';
|
||||
btn.className = `emby-button raised ${className}`;
|
||||
btn.style.margin = '0.5em';
|
||||
btn.style.padding = '0.4em 1em';
|
||||
btn.innerHTML = `<span>${text}</span>`;
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
if (headerButtons) {
|
||||
if (!document.querySelector('.btnMovieNightRecommend')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'emby-button raised btnMovieNightRecommend';
|
||||
btn.innerHTML = '<span>Recommend Film</span>';
|
||||
btn.style.marginLeft = '1em';
|
||||
btn.onclick = showRecommendation;
|
||||
headerButtons.appendChild(btn);
|
||||
}
|
||||
function createIconButton(icon, title, className, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.is = 'emby-button';
|
||||
btn.className = `button-flat detailButton emby-button ${className}`;
|
||||
btn.title = title;
|
||||
btn.innerHTML = `
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
|
||||
</div>
|
||||
`;
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
if (!document.querySelector('.btnMovieNightAddMovie')) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'emby-button raised btnMovieNightAddMovie';
|
||||
btn.innerHTML = '<span>Add Movie (STRM)</span>';
|
||||
btn.style.marginLeft = '1em';
|
||||
btn.onclick = promptAddMovie;
|
||||
headerButtons.appendChild(btn);
|
||||
async function injectUI() {
|
||||
// Check for onboarding
|
||||
await checkOnboarding();
|
||||
|
||||
// 1. Item Detail Page
|
||||
const detailButtons = document.querySelector('.mainDetailButtons');
|
||||
if (detailButtons) {
|
||||
const itemId = getItemIdFromUrl();
|
||||
if (itemId) {
|
||||
// MovieNight Rating
|
||||
if (!document.querySelector('.btnMovieNightRate')) {
|
||||
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, rateBtn);
|
||||
}
|
||||
// Mark Viewed in MovieNight
|
||||
if (!document.querySelector('.btnMovieNightMarkViewed')) {
|
||||
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, viewedBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const detailButtons = document.querySelector('.itemDetailButtons, .itemDetailsButtons');
|
||||
if (detailButtons && !document.querySelector('.movieNightRatingContainer')) {
|
||||
const itemId = getItemIdFromUrl();
|
||||
if (itemId) {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'movieNightRatingContainer';
|
||||
container.style.display = 'inline-flex';
|
||||
container.style.alignItems = 'center';
|
||||
container.style.marginLeft = '1em';
|
||||
// 2. Library Pages - Add text buttons to toolbar
|
||||
const toolBar = document.querySelector('.libraryPage:not(.itemDetailPage) .flex.align-items-center.justify-content-center.focuscontainer-x');
|
||||
if (toolBar && !document.querySelector('.btnMovieNightRecommend')) {
|
||||
toolBar.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', (e) => {
|
||||
e.preventDefault(); showRecommendation();
|
||||
}));
|
||||
toolBar.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', (e) => {
|
||||
e.preventDefault(); showAddMovieDialog();
|
||||
}));
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.innerText = 'MovieNight: ';
|
||||
label.style.marginRight = '0.5em';
|
||||
container.appendChild(label);
|
||||
// 3. Home Page - Prepend a MovieNight section
|
||||
const homeSections = document.querySelector('.sections.homeSectionsContainer');
|
||||
if (homeSections && !document.querySelector('.movieNightHomeButtons')) {
|
||||
const section = document.createElement('div');
|
||||
section.className = 'verticalSection movieNightHomeButtons';
|
||||
section.style.padding = '0 var(--sidePadding)';
|
||||
section.innerHTML = `
|
||||
<div class="sectionTitleContainer" style="display:flex; align-items:center; justify-content:space-between;">
|
||||
<h2 class="sectionTitle">MovieNight</h2>
|
||||
<span class="movieNightSyncStatus" style="font-size:0.8em; opacity:0.7;"></span>
|
||||
</div>
|
||||
<div class="movieNightBtnContainer" style="display:flex; flex-wrap:wrap; margin-top:0.5em;"></div>
|
||||
`;
|
||||
const btnContainer = section.querySelector('.movieNightBtnContainer');
|
||||
btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation));
|
||||
btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog));
|
||||
btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync));
|
||||
|
||||
const select = document.createElement('select');
|
||||
select.className = 'emby-select';
|
||||
select.style.padding = '0.2em';
|
||||
for (let i = 0; i <= 10; i++) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = i;
|
||||
opt.innerText = i === 0 ? 'Rate...' : i;
|
||||
select.appendChild(opt);
|
||||
}
|
||||
select.onchange = (e) => submitRating(itemId, e.target.value);
|
||||
container.appendChild(select);
|
||||
|
||||
detailButtons.appendChild(container);
|
||||
}
|
||||
homeSections.insertBefore(section, homeSections.firstChild);
|
||||
updateSyncStatus();
|
||||
}
|
||||
}
|
||||
|
||||
function insertInDetailRow(container, btn) {
|
||||
const moreBtn = container.querySelector('.btnMoreCommands');
|
||||
if (moreBtn) container.insertBefore(btn, moreBtn);
|
||||
else container.appendChild(btn);
|
||||
}
|
||||
|
||||
function getItemIdFromUrl() {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const queryString = window.location.hash.includes('?') ? window.location.hash.split('?')[1] : window.location.search;
|
||||
const params = new URLSearchParams(queryString);
|
||||
return params.get('id') || params.get('itemId');
|
||||
}
|
||||
|
||||
function createOverlay() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'dialogBackdrop dialogBackdropOpened';
|
||||
overlay.style.zIndex = '99998';
|
||||
overlay.style.backgroundColor = 'rgba(0,0,0,0.7)';
|
||||
overlay.style.position = 'fixed';
|
||||
overlay.style.top = '0'; overlay.style.left = '0'; overlay.style.right = '0'; overlay.style.bottom = '0';
|
||||
overlay.style.backdropFilter = 'blur(8px)';
|
||||
overlay.style.opacity = '1';
|
||||
return overlay;
|
||||
}
|
||||
|
||||
function createDialogBase(title) {
|
||||
const dialog = document.createElement('div');
|
||||
dialog.className = 'dialog';
|
||||
dialog.style.position = 'fixed';
|
||||
dialog.style.top = '50%'; dialog.style.left = '50%';
|
||||
dialog.style.transform = 'translate(-50%, -50%)';
|
||||
dialog.style.zIndex = '99999';
|
||||
dialog.style.padding = '2.5em';
|
||||
dialog.style.minWidth = '350px';
|
||||
dialog.style.backgroundColor = '#1a1a1a';
|
||||
dialog.style.borderRadius = '1.5em';
|
||||
dialog.style.color = 'white';
|
||||
dialog.style.boxShadow = '0 20px 50px rgba(0,0,0,0.8)';
|
||||
dialog.style.border = '1px solid #444';
|
||||
dialog.style.opacity = '1';
|
||||
|
||||
dialog.innerHTML = `
|
||||
<h2 style="margin-top:0; text-align:center; font-weight:400; color:white; opacity:1;">${title}</h2>
|
||||
<div class="dialog-content" style="margin:1.5em 0; opacity:1;"></div>
|
||||
<div class="dialog-footer" style="display:flex; gap:1em; opacity:1;">
|
||||
<button is="emby-button" class="emby-button button-flat btnCancel" style="flex:1; color: white !important; opacity:1;">Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
return dialog;
|
||||
}
|
||||
|
||||
async function showRatingDialog(itemId) {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Rate on MovieNight');
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
|
||||
content.innerHTML = `<div class="rating-grid" style="display:grid; grid-template-columns:repeat(5, 1fr); gap:0.8em;"></div>`;
|
||||
const grid = content.querySelector('.rating-grid');
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button'; btn.is = 'emby-button';
|
||||
btn.className = 'emby-button raised';
|
||||
btn.innerText = i;
|
||||
btn.style.padding = '0.8em 0';
|
||||
btn.style.textAlign = 'center';
|
||||
btn.style.display = 'flex';
|
||||
btn.style.alignItems = 'center';
|
||||
btn.style.justifyContent = 'center';
|
||||
btn.style.fontSize = '1.2em';
|
||||
btn.onclick = async () => { cleanup(); await submitRating(itemId, i); };
|
||||
grid.appendChild(btn);
|
||||
}
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function showAddMovieDialog() {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Add Movie (STRM)');
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
const footer = dialog.querySelector('.dialog-footer');
|
||||
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom:1em;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Movie Title (Required)</label>
|
||||
<input type="text" class="emby-input txtTitle" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="e.g. Inception">
|
||||
</div>
|
||||
<div style="display:flex; gap:1em; margin-bottom:1em;">
|
||||
<div style="flex:1;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Year</label>
|
||||
<input type="number" class="emby-input txtYear" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="2010">
|
||||
</div>
|
||||
<div style="flex:2;">
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">IMDb ID</label>
|
||||
<input type="text" class="emby-input txtImdb" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="tt1375666">
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:0.3em; font-size:0.9em; opacity:0.8; color:white;">Stream URL (Optional)</label>
|
||||
<input type="text" class="emby-input txtUrl" style="width:100%; box-sizing:border-box; background:#333; border:1px solid #555; color:white; padding:0.6em;" placeholder="http://...">
|
||||
</div>
|
||||
`;
|
||||
|
||||
const btnAdd = document.createElement('button');
|
||||
btnAdd.className = 'emby-button raised button-submit';
|
||||
btnAdd.style.flex = '2';
|
||||
btnAdd.style.backgroundColor = '#0064d2';
|
||||
btnAdd.innerHTML = '<span>Add Film</span>';
|
||||
footer.insertBefore(btnAdd, footer.firstChild);
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
btnAdd.onclick = async () => {
|
||||
const title = dialog.querySelector('.txtTitle').value;
|
||||
const year = dialog.querySelector('.txtYear').value;
|
||||
const imdbId = dialog.querySelector('.txtImdb').value;
|
||||
const url = dialog.querySelector('.txtUrl').value;
|
||||
if (!title) return;
|
||||
cleanup();
|
||||
await addMovie(title, url, year, imdbId);
|
||||
};
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
dialog.querySelector('.txtTitle').focus();
|
||||
}
|
||||
|
||||
async function showOnboardingDialog() {
|
||||
const overlay = createOverlay();
|
||||
const dialog = createDialogBase('Welcome to MovieNight!');
|
||||
dialog.style.minWidth = '450px';
|
||||
const content = dialog.querySelector('.dialog-content');
|
||||
const footer = dialog.querySelector('.dialog-footer');
|
||||
|
||||
content.innerHTML = `
|
||||
<p style="margin-bottom:1.5em; opacity:0.8; text-align:center;">Pick your preferences to get better recommendations.</p>
|
||||
<div style="margin-bottom:1.5em;">
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Favorite Genres</label>
|
||||
<div class="genre-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
<div style="margin-bottom:1.5em;">
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Preferred Eras</label>
|
||||
<div class="era-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; margin-bottom:0.6em; font-weight:600;">Content Types</label>
|
||||
<div class="type-chips" style="display:flex; flex-wrap:wrap; gap:0.5em;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const genres = ["Action", "Comedy", "Drama", "Sci-Fi", "Horror", "Thriller", "Animation", "Documentary"];
|
||||
const eras = ["1980s", "1990s", "2000s", "2010s", "2020s"];
|
||||
const types = ["FILM", "SERIES"];
|
||||
|
||||
const selections = { genres: new Set(), eras: new Set(), types: new Set() };
|
||||
|
||||
const createChip = (text, container, type) => {
|
||||
const chip = document.createElement('div');
|
||||
chip.innerText = text;
|
||||
chip.style.cssText = 'padding:0.4em 1em; border-radius:2em; border:1px solid #444; cursor:pointer; font-size:0.9em; transition:all 0.2s;';
|
||||
chip.onclick = () => {
|
||||
if (selections[type].has(text)) {
|
||||
selections[type].delete(text);
|
||||
chip.style.backgroundColor = 'transparent';
|
||||
chip.style.borderColor = '#444';
|
||||
} else {
|
||||
selections[type].add(text);
|
||||
chip.style.backgroundColor = '#0064d2';
|
||||
chip.style.borderColor = '#0064d2';
|
||||
}
|
||||
};
|
||||
container.appendChild(chip);
|
||||
};
|
||||
|
||||
genres.forEach(g => createChip(g, content.querySelector('.genre-chips'), 'genres'));
|
||||
eras.forEach(e => createChip(e, content.querySelector('.era-chips'), 'eras'));
|
||||
types.forEach(t => createChip(t, content.querySelector('.type-chips'), 'types'));
|
||||
|
||||
const btnSave = document.createElement('button');
|
||||
btnSave.className = 'emby-button raised button-submit';
|
||||
btnSave.style.flex = '2';
|
||||
btnSave.style.backgroundColor = '#0064d2';
|
||||
btnSave.innerHTML = '<span>Save & Start</span>';
|
||||
footer.insertBefore(btnSave, footer.firstChild);
|
||||
|
||||
const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); };
|
||||
|
||||
btnSave.onclick = async () => {
|
||||
const payload = {
|
||||
weightedGenres: Object.fromEntries([...selections.genres].map(g => [g, 5])),
|
||||
eras: [...selections.eras],
|
||||
contentTypes: [...selections.types]
|
||||
};
|
||||
cleanup();
|
||||
await completeOnboarding(payload);
|
||||
};
|
||||
|
||||
dialog.querySelector('.btnCancel').onclick = cleanup;
|
||||
overlay.onclick = (e) => { if (e.target === overlay) cleanup(); };
|
||||
overlay.appendChild(dialog);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
async function checkOnboarding() {
|
||||
if (window.movieNightOnboardingChecked) return;
|
||||
window.movieNightOnboardingChecked = true;
|
||||
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
const prefs = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Preferences`));
|
||||
if (!prefs || (!Object.keys(prefs.weightedGenres || {}).length && !prefs.eras?.length)) {
|
||||
showOnboardingDialog();
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.status === 404) showOnboardingDialog();
|
||||
}
|
||||
}
|
||||
|
||||
async function completeOnboarding(payload) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Onboarding`),
|
||||
data: JSON.stringify(payload),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Welcome! Your preferences have been saved.');
|
||||
} catch (err) {
|
||||
showMsg('Failed to save onboarding preferences.');
|
||||
}
|
||||
}
|
||||
|
||||
async function showRecommendation() {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
@@ -91,30 +360,51 @@
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get recommendations', err);
|
||||
showMsg('Failed to get recommendations from MovieNight.');
|
||||
showMsg('Failed to get recommendations. Check your API token and MovieNight status.');
|
||||
}
|
||||
}
|
||||
|
||||
async function promptAddMovie() {
|
||||
const title = prompt("Enter movie title:");
|
||||
if (!title) return;
|
||||
|
||||
async function addMovie(title, url, year, imdbId) {
|
||||
try {
|
||||
const data = { title, url };
|
||||
if (year) data.year = parseInt(year);
|
||||
if (imdbId) data.imdbId = imdbId;
|
||||
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Films`),
|
||||
data: JSON.stringify({ title: title }),
|
||||
data: JSON.stringify(data),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg(`STRM file created for "${title}". Refresh your library to see it.`);
|
||||
} catch (err) {
|
||||
console.error('Failed to create movie', err);
|
||||
showMsg('Failed to create movie. Check plugin configuration and logs.');
|
||||
showMsg('Failed to create movie. Ensure STRM output path is configured.');
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
try {
|
||||
await ApiClient.ajax({ type: 'POST', url: ApiClient.getUrl(`MovieNight/Sync`) });
|
||||
showMsg('Library sync triggered!');
|
||||
setTimeout(updateSyncStatus, 2000);
|
||||
} catch (err) {
|
||||
showMsg('Failed to trigger sync.');
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSyncStatus() {
|
||||
const statusEl = document.querySelector('.movieNightSyncStatus');
|
||||
if (!statusEl) return;
|
||||
try {
|
||||
const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`));
|
||||
if (state && state.lastSyncAt) {
|
||||
statusEl.innerText = `Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`;
|
||||
}
|
||||
} catch (err) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function submitRating(itemId, score) {
|
||||
if (score === "0") return;
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
@@ -123,14 +413,37 @@
|
||||
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Rating submitted!');
|
||||
showMsg('Rating submitted to MovieNight!');
|
||||
} catch (err) {
|
||||
console.error('Failed to submit rating', err);
|
||||
showMsg('Failed to submit rating to MovieNight.');
|
||||
showMsg('Failed to submit rating.');
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(injectUI);
|
||||
async function submitViewed(itemId) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Library/Films/${itemId}/Viewed`),
|
||||
data: JSON.stringify({ watchedAt: new Date().toISOString() }),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Marked as viewed in MovieNight!');
|
||||
} catch (err) {
|
||||
showMsg('Failed to mark as viewed.');
|
||||
}
|
||||
}
|
||||
|
||||
let timeout;
|
||||
const throttledInject = () => {
|
||||
if (timeout) return;
|
||||
timeout = setTimeout(() => {
|
||||
injectUI();
|
||||
timeout = null;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const observer = new MutationObserver(throttledInject);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
|
||||
injectUI();
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.MovieNight.Services;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -12,7 +15,6 @@ namespace Jellyfin.Plugin.MovieNight.Controllers;
|
||||
/// Admin endpoints for the MovieNight plugin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("MovieNight")]
|
||||
public class MovieNightController : ControllerBase
|
||||
{
|
||||
@@ -22,17 +24,26 @@ public class MovieNightController : ControllerBase
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
|
||||
/// </summary>
|
||||
public MovieNightController(MovieNightBackendClient backendClient, MovieNightSyncService syncService)
|
||||
public MovieNightController(
|
||||
MovieNightBackendClient backendClient,
|
||||
MovieNightSyncService syncService)
|
||||
{
|
||||
_backendClient = backendClient;
|
||||
_syncService = syncService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ping endpoint for connectivity checks.
|
||||
/// </summary>
|
||||
[HttpGet("Ping")]
|
||||
public ActionResult Ping() => Ok("Pong");
|
||||
|
||||
/// <summary>
|
||||
/// Returns plugin status.
|
||||
/// </summary>
|
||||
/// <returns>Status response.</returns>
|
||||
[HttpGet("Status")]
|
||||
[Authorize]
|
||||
public ActionResult<MovieNightPluginStatus> GetStatus()
|
||||
{
|
||||
var configuration = Plugin.Instance?.Configuration;
|
||||
@@ -50,6 +61,7 @@ public class MovieNightController : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Connection result.</returns>
|
||||
[HttpPost("TestConnection")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<MovieNightConnectionResult>> TestConnection(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -61,6 +73,7 @@ public class MovieNightController : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response.</returns>
|
||||
[HttpPost("Sync")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<string>> Sync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -73,6 +86,7 @@ public class MovieNightController : ControllerBase
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response.</returns>
|
||||
[HttpGet("SyncState")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<string>> SyncState(CancellationToken cancellationToken)
|
||||
{
|
||||
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
|
||||
@@ -82,6 +96,7 @@ public class MovieNightController : ControllerBase
|
||||
/// Gets recommendations for the current user.
|
||||
/// </summary>
|
||||
[HttpGet("Users/{userId}/Recommendations")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<string>> GetRecommendations(
|
||||
[FromRoute] string userId,
|
||||
[FromQuery] string? contentType,
|
||||
@@ -96,6 +111,7 @@ public class MovieNightController : ControllerBase
|
||||
/// Posts a rating for a film.
|
||||
/// </summary>
|
||||
[HttpPost("Users/{userId}/Ratings/Films/{filmId}")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> PostRating(
|
||||
[FromRoute] string userId,
|
||||
[FromRoute] string filmId,
|
||||
@@ -110,6 +126,7 @@ public class MovieNightController : ControllerBase
|
||||
/// Marks a film as viewed.
|
||||
/// </summary>
|
||||
[HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> MarkViewed(
|
||||
[FromRoute] string userId,
|
||||
[FromRoute] string filmId,
|
||||
@@ -121,9 +138,37 @@ public class MovieNightController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new film by generating a .strm file.
|
||||
/// Gets user preferences.
|
||||
/// </summary>
|
||||
[HttpGet("Users/{userId}/Preferences")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<string?>> GetPreferences(
|
||||
[FromRoute] string userId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes onboarding for a user.
|
||||
/// </summary>
|
||||
[HttpPost("Users/{userId}/Onboarding")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> CompleteOnboarding(
|
||||
[FromRoute] string userId,
|
||||
[FromBody] object payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _backendClient.CompleteOnboardingAsync(userId, payload, cancellationToken).ConfigureAwait(false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new film by generating a .strm file in a folder-per-movie structure.
|
||||
/// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm
|
||||
/// </summary>
|
||||
[HttpPost("Films")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult> CreateFilm([FromBody] CreateFilmRequest request)
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
@@ -132,22 +177,45 @@ public class MovieNightController : ControllerBase
|
||||
return BadRequest("STRM output path is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest("Movie title is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(config.StrmOutputPath))
|
||||
// Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]"
|
||||
var folderName = request.Title.Trim();
|
||||
if (request.Year.HasValue)
|
||||
{
|
||||
Directory.CreateDirectory(config.StrmOutputPath);
|
||||
folderName += $" ({request.Year})";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ImdbId))
|
||||
{
|
||||
var ttId = request.ImdbId.Trim().ToLowerInvariant();
|
||||
if (!ttId.StartsWith("tt")) ttId = "tt" + ttId;
|
||||
folderName += $" [imdbid-{ttId}]";
|
||||
}
|
||||
|
||||
var safeTitle = string.Join("_", request.Title.Split(Path.GetInvalidFileNameChars()));
|
||||
var fileName = $"{safeTitle}.strm";
|
||||
var filePath = Path.Combine(config.StrmOutputPath, fileName);
|
||||
// Sanitize for file system
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray());
|
||||
|
||||
// Placeholder content for the .strm file.
|
||||
// In a real scenario, this could be a URL provided in the request.
|
||||
await System.IO.File.WriteAllTextAsync(filePath, "http://placeholder.url/upload_me_later").ConfigureAwait(false);
|
||||
var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName);
|
||||
if (!Directory.Exists(movieDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(movieDirectory);
|
||||
}
|
||||
|
||||
return Ok(new { FilePath = filePath });
|
||||
var filePath = Path.Combine(movieDirectory, $"{safeFolderName}.strm");
|
||||
|
||||
var strmContent = string.IsNullOrWhiteSpace(request.Url)
|
||||
? "http://placeholder.url/upload_me_later"
|
||||
: request.Url.Trim();
|
||||
|
||||
await System.IO.File.WriteAllTextAsync(filePath, strmContent).ConfigureAwait(false);
|
||||
|
||||
return Ok(new { FilePath = filePath, FolderName = safeFolderName });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -159,7 +227,7 @@ public class MovieNightController : ControllerBase
|
||||
/// <summary>
|
||||
/// Create film request.
|
||||
/// </summary>
|
||||
public sealed record CreateFilmRequest(string Title);
|
||||
public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
|
||||
|
||||
/// <summary>
|
||||
/// Rating request.
|
||||
|
||||
@@ -167,6 +167,34 @@ public class MovieNightBackendClient
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets user preferences.
|
||||
/// </summary>
|
||||
public async Task<string?> GetPreferencesAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences");
|
||||
if (request is null) return null;
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
|
||||
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Completes onboarding for a user.
|
||||
/// </summary>
|
||||
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding");
|
||||
if (request is null) return;
|
||||
|
||||
request.Content = JsonContent.Create(payload, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an event payload to the backend event endpoint.
|
||||
/// </summary>
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.project.movienight
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan
|
||||
import org.springframework.boot.runApplication
|
||||
import org.springframework.scheduling.annotation.EnableScheduling
|
||||
|
||||
@SpringBootApplication
|
||||
@EnableScheduling
|
||||
@ConfigurationPropertiesScan("com.project.movienight.config")
|
||||
class MovieNightApplication
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.project.movienight.adapters.jellyfin
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import org.springframework.stereotype.Service
|
||||
import java.net.URI
|
||||
import java.net.http.HttpClient
|
||||
import java.net.http.HttpRequest
|
||||
import java.net.http.HttpResponse
|
||||
import java.time.Duration
|
||||
|
||||
data class JellyfinRemoteUser(
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
|
||||
data class JellyfinLibraryItemSnapshot(
|
||||
val jellyfinItemId: String,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val contentType: ContentType,
|
||||
val releaseYear: Int?,
|
||||
val genres: List<String>,
|
||||
val cast: List<String>,
|
||||
val directors: List<String>,
|
||||
val platformRating: Double?,
|
||||
val imdbRating: Double?,
|
||||
val externalUrl: String?,
|
||||
val jellyfinLibraryId: String?,
|
||||
val isPlayed: Boolean,
|
||||
)
|
||||
|
||||
@Service
|
||||
class JellyfinApiClient(
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
private val objectMapper: ObjectMapper,
|
||||
) {
|
||||
private val httpClient: HttpClient =
|
||||
HttpClient
|
||||
.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||
.build()
|
||||
|
||||
fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||
request("Users")
|
||||
.asItems()
|
||||
.mapNotNull { node ->
|
||||
val id = node.fieldText("Id") ?: return@mapNotNull null
|
||||
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
||||
}
|
||||
|
||||
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||
@Suppress("MaxLineLength")
|
||||
request(
|
||||
"Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData",
|
||||
).asItems().mapNotNull { node ->
|
||||
val itemId = node.fieldText("Id") ?: return@mapNotNull null
|
||||
val providerIds = node["ProviderIds"]
|
||||
val imdbId = providerIds?.fieldText("Imdb")
|
||||
val people = node["People"]
|
||||
val cast = people?.peopleByType("Actor", "GuestStar") ?: emptyList()
|
||||
val directors = people?.peopleByType("Director") ?: emptyList()
|
||||
JellyfinLibraryItemSnapshot(
|
||||
jellyfinItemId = itemId,
|
||||
title = node.fieldText("Name") ?: itemId,
|
||||
description = node.fieldText("Overview") ?: "",
|
||||
contentType = mapContentType(node.fieldText("Type")),
|
||||
releaseYear = node["ProductionYear"]?.takeUnless { it.isNull }?.asInt(),
|
||||
genres = node["Genres"]?.textList() ?: emptyList(),
|
||||
cast = cast,
|
||||
directors = directors,
|
||||
platformRating = node["CommunityRating"]?.takeUnless { it.isNull }?.asDouble(),
|
||||
imdbRating = null,
|
||||
externalUrl = imdbId?.let { "https://www.imdb.com/title/$it/" },
|
||||
jellyfinLibraryId = node.fieldText("ParentId"),
|
||||
isPlayed =
|
||||
node["UserData"]?.booleanField("Played") ?: node["UserData"]?.booleanField("IsPlayed") ?: false,
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(path: String): JsonNode {
|
||||
val uri = URI.create("${properties.baseUrl.trimEnd('/')}/$path")
|
||||
val request =
|
||||
HttpRequest
|
||||
.newBuilder(uri)
|
||||
.timeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||
.header("Accept", "application/json")
|
||||
.header("X-Emby-Token", properties.apiKey)
|
||||
.GET()
|
||||
.build()
|
||||
|
||||
val response =
|
||||
try {
|
||||
httpClient.send(request, HttpResponse.BodyHandlers.ofString())
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") exception: Exception,
|
||||
) {
|
||||
throw IllegalStateException("Failed to call Jellyfin at $uri", exception)
|
||||
}
|
||||
|
||||
check(response.statusCode() in 200..299) {
|
||||
"Jellyfin request failed with status ${response.statusCode()} for $uri"
|
||||
}
|
||||
|
||||
return objectMapper.readTree(response.body())
|
||||
}
|
||||
|
||||
private fun JsonNode.asItems(): List<JsonNode> =
|
||||
when {
|
||||
isArray -> map { it }
|
||||
has("Items") && this["Items"].isArray -> this["Items"].map { it }
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private fun JsonNode.fieldText(name: String): String? =
|
||||
get(name)?.takeUnless { it.isNull }?.asText()?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun JsonNode.booleanField(name: String): Boolean? = get(name)?.takeUnless { it.isNull }?.asBoolean()
|
||||
|
||||
private fun JsonNode.textList(): List<String> =
|
||||
takeIf { it.isArray }?.mapNotNull { item ->
|
||||
item.takeUnless { it.isNull }?.asText()?.takeIf { text -> text.isNotBlank() }
|
||||
}
|
||||
?: emptyList()
|
||||
|
||||
private fun JsonNode.peopleByType(vararg types: String): List<String> {
|
||||
if (!isArray) return emptyList()
|
||||
return mapNotNull { person ->
|
||||
val type = person.fieldText("Type") ?: return@mapNotNull null
|
||||
if (types.any { it.equals(type, ignoreCase = true) }) person.fieldText("Name") else null
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapContentType(value: String?): ContentType =
|
||||
when (value?.lowercase()) {
|
||||
"movie" -> ContentType.FILM
|
||||
"series" -> ContentType.SERIES
|
||||
"episode" -> ContentType.EPISODE
|
||||
else -> ContentType.OTHER
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.project.movienight.adapters.metrics
|
||||
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import com.project.movienight.domain.model.RecommendationEventType
|
||||
import io.micrometer.core.instrument.Counter
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.micrometer.core.instrument.Timer
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@Service
|
||||
class BusinessMetricsService(
|
||||
private val meterRegistry: MeterRegistry,
|
||||
) {
|
||||
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
|
||||
private val ratingsSubmitted: Counter = meterRegistry.counter("business_ratings_submitted_total")
|
||||
private val libraryEvents: Counter = meterRegistry.counter("business_library_events_total")
|
||||
private val jellyfinSyncRuns: Counter = meterRegistry.counter("business_jellyfin_sync_runs_total")
|
||||
private val jellyfinSyncedUsers: Counter = meterRegistry.counter("business_jellyfin_synced_users_total")
|
||||
private val jellyfinSkippedUsers: Counter = meterRegistry.counter("business_jellyfin_skipped_users_total")
|
||||
private val jellyfinSyncedItems: Counter = meterRegistry.counter("business_jellyfin_synced_items_total")
|
||||
private val jellyfinSyncDuration: Timer =
|
||||
Timer
|
||||
.builder("business_jellyfin_sync_duration_seconds")
|
||||
.publishPercentileHistogram()
|
||||
.register(meterRegistry)
|
||||
private val jellyfinSyncFailures: Counter = meterRegistry.counter("business_jellyfin_sync_failures_total")
|
||||
private val jellyfinUnmappedUsersGaugeValue = AtomicInteger(0)
|
||||
|
||||
init {
|
||||
meterRegistry.gauge("business_jellyfin_unmapped_users", jellyfinUnmappedUsersGaugeValue)
|
||||
}
|
||||
|
||||
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
|
||||
|
||||
fun recordRecommendationRequest() {
|
||||
recommendationRequests.increment()
|
||||
}
|
||||
|
||||
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
|
||||
Counter
|
||||
.builder("recommendation_weights_updated_total")
|
||||
.tag("eventType", eventType.name)
|
||||
.register(meterRegistry)
|
||||
.increment()
|
||||
}
|
||||
|
||||
fun recordRatingSubmitted() {
|
||||
ratingsSubmitted.increment()
|
||||
}
|
||||
|
||||
fun recordLibraryEvent() {
|
||||
libraryEvents.increment()
|
||||
}
|
||||
|
||||
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||
jellyfinSyncRuns.increment()
|
||||
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
||||
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
||||
jellyfinSyncedItems.increment(summary.syncedItems.toDouble())
|
||||
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
fun recordJellyfinSyncFailure() {
|
||||
jellyfinSyncFailures.increment()
|
||||
}
|
||||
|
||||
fun recordJellyfinUnmappedUser() {
|
||||
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
||||
}
|
||||
|
||||
fun recordBackendWriteFailure() {
|
||||
backendWriteFailures.increment()
|
||||
}
|
||||
}
|
||||
+37
@@ -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,
|
||||
)
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.project.movienight.adapters.persistence.entity
|
||||
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class JellyfinSyncStateEntity(
|
||||
val userId: UUID,
|
||||
val lastSyncedAt: LocalDateTime?,
|
||||
val lastSuccessfulSyncAt: LocalDateTime?,
|
||||
val lastError: String?,
|
||||
val syncedItemCount: Int,
|
||||
)
|
||||
|
||||
fun JellyfinSyncStateEntity.toDomain(): JellyfinSyncState =
|
||||
JellyfinSyncState(
|
||||
userId = userId,
|
||||
lastSyncedAt = lastSyncedAt,
|
||||
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
|
||||
lastError = lastError,
|
||||
syncedItemCount = syncedItemCount,
|
||||
)
|
||||
|
||||
fun JellyfinSyncState.toEntity(): JellyfinSyncStateEntity =
|
||||
JellyfinSyncStateEntity(
|
||||
userId = userId,
|
||||
lastSyncedAt = lastSyncedAt,
|
||||
lastSuccessfulSyncAt = lastSuccessfulSyncAt,
|
||||
lastError = lastError,
|
||||
syncedItemCount = syncedItemCount,
|
||||
)
|
||||
@@ -11,6 +11,7 @@ data class UserEntity(
|
||||
val email: String,
|
||||
val provider: String?,
|
||||
val providerId: String?,
|
||||
val jellyfinUserId: String?,
|
||||
val createdAt: LocalDateTime,
|
||||
)
|
||||
|
||||
@@ -20,6 +21,8 @@ fun UserEntity.toDomain(): User =
|
||||
name = name,
|
||||
email = email,
|
||||
library = null,
|
||||
preferences = null,
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
)
|
||||
|
||||
fun User.toEntity(
|
||||
@@ -33,5 +36,6 @@ fun User.toEntity(
|
||||
email = email,
|
||||
provider = provider?.name,
|
||||
providerId = providerId,
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
+41
@@ -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 }),
|
||||
)
|
||||
+25
-5
@@ -18,6 +18,7 @@ class FilmLibraryRepository(
|
||||
filmId = UUID.fromString(rs.getString("film_id")),
|
||||
comment = rs.getString("comment"),
|
||||
isViewed = rs.getBoolean("is_viewed"),
|
||||
watchedAt = rs.getTimestamp("watched_at")?.toLocalDateTime(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -26,26 +27,28 @@ class FilmLibraryRepository(
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE favorites
|
||||
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?
|
||||
SET user_id = ?, film_id = ?, comment = ?, is_viewed = ?, watched_at = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
filmLibrary.userId,
|
||||
filmLibrary.filmId,
|
||||
filmLibrary.comment,
|
||||
filmLibrary.isViewed,
|
||||
filmLibrary.watchedAt,
|
||||
filmLibrary.id,
|
||||
)
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO favorites (id, user_id, film_id, comment, is_viewed, watched_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
filmLibrary.id,
|
||||
filmLibrary.userId,
|
||||
filmLibrary.filmId,
|
||||
filmLibrary.comment,
|
||||
filmLibrary.isViewed,
|
||||
filmLibrary.watchedAt,
|
||||
)
|
||||
}
|
||||
return filmLibrary
|
||||
@@ -54,16 +57,33 @@ class FilmLibraryRepository(
|
||||
override fun findById(id: UUID): FilmLibrary? {
|
||||
val entries =
|
||||
jdbc.query(
|
||||
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites WHERE id = ?",
|
||||
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
|
||||
filmLibraryRowMapper,
|
||||
id,
|
||||
)
|
||||
return entries.firstOrNull()
|
||||
}
|
||||
|
||||
override fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmLibrary? {
|
||||
val entries =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id, user_id, film_id, comment, is_viewed, watched_at
|
||||
FROM favorites WHERE user_id = ? AND film_id = ?
|
||||
""".trimIndent(),
|
||||
filmLibraryRowMapper,
|
||||
userId,
|
||||
filmId,
|
||||
)
|
||||
return entries.firstOrNull()
|
||||
}
|
||||
|
||||
override fun findAll(): List<FilmLibrary> =
|
||||
jdbc.query(
|
||||
"SELECT id, user_id, film_id, comment, is_viewed FROM favorites",
|
||||
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
|
||||
filmLibraryRowMapper,
|
||||
)
|
||||
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.adapters.persistence.entity.FilmRatingEntity
|
||||
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.sql.ResultSet
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class FilmRatingRepository(
|
||||
private val jdbc: JdbcTemplate,
|
||||
) : FilmRatingRepositoryPort {
|
||||
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||
FilmRatingEntity(
|
||||
id = UUID.fromString(rs.getString("id")),
|
||||
userId = UUID.fromString(rs.getString("user_id")),
|
||||
filmId = UUID.fromString(rs.getString("film_id")),
|
||||
score = rs.getInt("score"),
|
||||
note = rs.getString("note"),
|
||||
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun save(rating: FilmRating): FilmRating {
|
||||
val entity = rating.toEntity()
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE film_ratings
|
||||
SET score = ?,
|
||||
note = ?,
|
||||
updated_at = ?
|
||||
WHERE user_id = ?
|
||||
AND film_id = ?
|
||||
""".trimIndent(),
|
||||
entity.score,
|
||||
entity.note,
|
||||
LocalDateTime.now(),
|
||||
entity.userId,
|
||||
entity.filmId,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO film_ratings (
|
||||
id,
|
||||
user_id,
|
||||
film_id,
|
||||
score,
|
||||
note,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
entity.id,
|
||||
entity.userId,
|
||||
entity.filmId,
|
||||
entity.score,
|
||||
entity.note,
|
||||
entity.createdAt,
|
||||
entity.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
return rating
|
||||
}
|
||||
|
||||
override fun findByUserId(userId: UUID): List<FilmRating> =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT id,
|
||||
user_id,
|
||||
film_id,
|
||||
score,
|
||||
note,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM film_ratings
|
||||
WHERE user_id = ?
|
||||
""".trimIndent(),
|
||||
rowMapper,
|
||||
userId,
|
||||
).map { it.toDomain() }
|
||||
|
||||
override fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmRating? =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT id,
|
||||
user_id,
|
||||
film_id,
|
||||
score,
|
||||
note,
|
||||
created_at,
|
||||
updated_at
|
||||
FROM film_ratings
|
||||
WHERE user_id = ?
|
||||
AND film_id = ?
|
||||
""".trimIndent(),
|
||||
rowMapper,
|
||||
userId,
|
||||
filmId,
|
||||
).firstOrNull()
|
||||
?.toDomain()
|
||||
}
|
||||
+185
-6
@@ -1,6 +1,8 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.adapters.persistence.jdbc.support.DelimitedValueCodec
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
@@ -16,6 +18,21 @@ class FilmRepository(
|
||||
id = UUID.fromString(rs.getString("id")),
|
||||
title = rs.getString("title"),
|
||||
description = rs.getString("description"),
|
||||
contentType =
|
||||
runCatching {
|
||||
ContentType.valueOf(
|
||||
rs.getString("content_type"),
|
||||
)
|
||||
}.getOrDefault(ContentType.FILM),
|
||||
releaseYear = rs.getObject("release_year")?.let { (it as Number).toInt() },
|
||||
genres = DelimitedValueCodec.decodeList(rs.getString("genres")),
|
||||
cast = DelimitedValueCodec.decodeList(rs.getString("cast_members")),
|
||||
directors = DelimitedValueCodec.decodeList(rs.getString("directors")),
|
||||
imdbRating = rs.getObject("imdb_rating")?.let { (it as Number).toDouble() },
|
||||
platformRating = rs.getObject("platform_rating")?.let { (it as Number).toDouble() },
|
||||
externalUrl = rs.getString("external_url"),
|
||||
jellyfinItemId = rs.getString("jellyfin_item_id"),
|
||||
jellyfinLibraryId = rs.getString("jellyfin_library_id"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -24,22 +41,67 @@ class FilmRepository(
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE films
|
||||
SET title = ?, description = ?
|
||||
SET title = ?,
|
||||
description = ?,
|
||||
content_type = ?,
|
||||
release_year = ?,
|
||||
genres = ?,
|
||||
cast_members = ?,
|
||||
directors = ?,
|
||||
imdb_rating = ?,
|
||||
platform_rating = ?,
|
||||
external_url = ?,
|
||||
jellyfin_item_id = ?,
|
||||
jellyfin_library_id = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
film.title,
|
||||
film.description,
|
||||
film.contentType.name,
|
||||
film.releaseYear,
|
||||
DelimitedValueCodec.encodeList(film.genres),
|
||||
DelimitedValueCodec.encodeList(film.cast),
|
||||
DelimitedValueCodec.encodeList(film.directors),
|
||||
film.imdbRating,
|
||||
film.platformRating,
|
||||
film.externalUrl,
|
||||
film.jellyfinItemId,
|
||||
film.jellyfinLibraryId,
|
||||
film.id,
|
||||
)
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO films (id, title, description)
|
||||
VALUES (?, ?, ?)
|
||||
INSERT INTO films (
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
film.id,
|
||||
film.title,
|
||||
film.description,
|
||||
film.contentType.name,
|
||||
film.releaseYear,
|
||||
DelimitedValueCodec.encodeList(film.genres),
|
||||
DelimitedValueCodec.encodeList(film.cast),
|
||||
DelimitedValueCodec.encodeList(film.directors),
|
||||
film.imdbRating,
|
||||
film.platformRating,
|
||||
film.externalUrl,
|
||||
film.jellyfinItemId,
|
||||
film.jellyfinLibraryId,
|
||||
)
|
||||
}
|
||||
return film
|
||||
@@ -48,20 +110,137 @@ class FilmRepository(
|
||||
override fun findById(id: UUID): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"SELECT id, title, description FROM films WHERE id = ?",
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
id,
|
||||
)
|
||||
return films.firstOrNull()
|
||||
}
|
||||
|
||||
override fun findByJellyfinItemId(jellyfinItemId: String): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE jellyfin_item_id = ?
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
jellyfinItemId,
|
||||
)
|
||||
return films.firstOrNull()
|
||||
}
|
||||
|
||||
override fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE jellyfin_library_id = ?
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
jellyfinLibraryId,
|
||||
)
|
||||
return films.firstOrNull()
|
||||
}
|
||||
|
||||
override fun findAll(): List<Film> =
|
||||
jdbc.query(
|
||||
"SELECT id, title, description FROM films",
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
)
|
||||
|
||||
override fun findByTitle(title: String): Film? {
|
||||
val films =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id,
|
||||
title,
|
||||
description,
|
||||
content_type,
|
||||
release_year,
|
||||
genres,
|
||||
cast_members,
|
||||
directors,
|
||||
imdb_rating,
|
||||
platform_rating,
|
||||
external_url,
|
||||
jellyfin_item_id,
|
||||
jellyfin_library_id
|
||||
FROM films
|
||||
WHERE title = ?
|
||||
ORDER BY id
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
filmRowMapper,
|
||||
title,
|
||||
)
|
||||
return films.firstOrNull()
|
||||
}
|
||||
|
||||
override fun deleteById(id: UUID) {
|
||||
jdbc.update("DELETE FROM films WHERE id = ?", id)
|
||||
jdbc.update(
|
||||
"""
|
||||
DELETE FROM films
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -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)
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.adapters.persistence.entity.JellyfinSyncStateEntity
|
||||
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.sql.ResultSet
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JellyfinSyncStateRepository(
|
||||
private val jdbc: JdbcTemplate,
|
||||
) : JellyfinSyncStateRepositoryPort {
|
||||
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||
JellyfinSyncStateEntity(
|
||||
userId = UUID.fromString(rs.getString("user_id")),
|
||||
lastSyncedAt = rs.getTimestamp("last_synced_at")?.toLocalDateTime(),
|
||||
lastSuccessfulSyncAt = rs.getTimestamp("last_successful_sync_at")?.toLocalDateTime(),
|
||||
lastError = rs.getString("last_error"),
|
||||
syncedItemCount = rs.getInt("synced_item_count"),
|
||||
)
|
||||
}
|
||||
|
||||
override fun save(state: JellyfinSyncState): JellyfinSyncState {
|
||||
val entity = state.toEntity()
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE jellyfin_sync_state
|
||||
SET last_synced_at = ?,
|
||||
last_successful_sync_at = ?,
|
||||
last_error = ?,
|
||||
synced_item_count = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE user_id = ?
|
||||
""".trimIndent(),
|
||||
entity.lastSyncedAt,
|
||||
entity.lastSuccessfulSyncAt,
|
||||
entity.lastError,
|
||||
entity.syncedItemCount,
|
||||
entity.userId,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO jellyfin_sync_state (
|
||||
user_id,
|
||||
last_synced_at,
|
||||
last_successful_sync_at,
|
||||
last_error,
|
||||
synced_item_count
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
entity.userId,
|
||||
entity.lastSyncedAt,
|
||||
entity.lastSuccessfulSyncAt,
|
||||
entity.lastError,
|
||||
entity.syncedItemCount,
|
||||
)
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
override fun findByUserId(userId: UUID): JellyfinSyncState? =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT user_id,
|
||||
last_synced_at,
|
||||
last_successful_sync_at,
|
||||
last_error,
|
||||
synced_item_count
|
||||
FROM jellyfin_sync_state
|
||||
WHERE user_id = ?
|
||||
""".trimIndent(),
|
||||
rowMapper,
|
||||
userId,
|
||||
).firstOrNull()
|
||||
?.toDomain()
|
||||
|
||||
override fun findAll(): List<JellyfinSyncState> =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT user_id,
|
||||
last_synced_at,
|
||||
last_successful_sync_at,
|
||||
last_error,
|
||||
synced_item_count
|
||||
FROM jellyfin_sync_state
|
||||
""".trimIndent(),
|
||||
rowMapper,
|
||||
).map { it.toDomain() }
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
||||
import com.project.movienight.domain.model.RecommendationEvent
|
||||
import com.project.movienight.domain.model.RecommendationEventType
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.sql.ResultSet
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class RecommendationEventRepository(
|
||||
private val jdbc: JdbcTemplate,
|
||||
) : RecommendationEventRepositoryPort {
|
||||
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||
RecommendationEvent(
|
||||
id = UUID.fromString(rs.getString("id")),
|
||||
userId = UUID.fromString(rs.getString("user_id")),
|
||||
filmId = UUID.fromString(rs.getString("film_id")),
|
||||
eventType = RecommendationEventType.valueOf(rs.getString("event_type")),
|
||||
score = rs.getObject("score")?.let { (it as Number).toDouble() },
|
||||
relevanceScore = rs.getObject("relevance_score")?.let { (it as Number).toDouble() },
|
||||
qualityScore = rs.getObject("quality_score")?.let { (it as Number).toDouble() },
|
||||
contextScore = rs.getObject("context_score")?.let { (it as Number).toDouble() },
|
||||
noveltyScore = rs.getObject("novelty_score")?.let { (it as Number).toDouble() },
|
||||
diversityScore = rs.getObject("diversity_score")?.let { (it as Number).toDouble() },
|
||||
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun save(event: RecommendationEvent): RecommendationEvent {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO recommendation_events (
|
||||
id,
|
||||
user_id,
|
||||
film_id,
|
||||
event_type,
|
||||
score,
|
||||
relevance_score,
|
||||
quality_score,
|
||||
context_score,
|
||||
novelty_score,
|
||||
diversity_score,
|
||||
created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
event.id,
|
||||
event.userId,
|
||||
event.filmId,
|
||||
event.eventType.name,
|
||||
event.score,
|
||||
event.relevanceScore,
|
||||
event.qualityScore,
|
||||
event.contextScore,
|
||||
event.noveltyScore,
|
||||
event.diversityScore,
|
||||
event.createdAt,
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
override fun findByUserId(userId: UUID): List<RecommendationEvent> =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id,
|
||||
user_id,
|
||||
film_id,
|
||||
event_type,
|
||||
score,
|
||||
relevance_score,
|
||||
quality_score,
|
||||
context_score,
|
||||
novelty_score,
|
||||
diversity_score,
|
||||
created_at
|
||||
FROM recommendation_events
|
||||
WHERE user_id = ?
|
||||
ORDER BY created_at DESC
|
||||
""".trimIndent(),
|
||||
rowMapper,
|
||||
userId,
|
||||
)
|
||||
|
||||
override fun findLatestRecommended(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): RecommendationEvent? =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT id,
|
||||
user_id,
|
||||
film_id,
|
||||
event_type,
|
||||
score,
|
||||
relevance_score,
|
||||
quality_score,
|
||||
context_score,
|
||||
novelty_score,
|
||||
diversity_score,
|
||||
created_at
|
||||
FROM recommendation_events
|
||||
WHERE user_id = ?
|
||||
AND film_id = ?
|
||||
AND event_type = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
""".trimIndent(),
|
||||
rowMapper,
|
||||
userId,
|
||||
filmId,
|
||||
RecommendationEventType.RECOMMENDED.name,
|
||||
).firstOrNull()
|
||||
}
|
||||
+97
@@ -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()
|
||||
}
|
||||
+130
@@ -0,0 +1,130 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.sql.ResultSet
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class UserRecommendationWeightsRepository(
|
||||
private val jdbc: JdbcTemplate,
|
||||
) : UserRecommendationWeightsRepositoryPort {
|
||||
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||
UserRecommendationWeights(
|
||||
userId = UUID.fromString(rs.getString("user_id")),
|
||||
relevanceWeight = rs.getDouble("relevance_weight"),
|
||||
qualityWeight = rs.getDouble("quality_weight"),
|
||||
contextWeight = rs.getDouble("context_weight"),
|
||||
noveltyWeight = rs.getDouble("novelty_weight"),
|
||||
diversityWeight = rs.getDouble("diversity_weight"),
|
||||
genreVectorWeight = rs.getDouble("genre_vector_weight"),
|
||||
plotVectorWeight = rs.getDouble("plot_vector_weight"),
|
||||
moodVectorWeight = rs.getDouble("mood_vector_weight"),
|
||||
eraVectorWeight = rs.getDouble("era_vector_weight"),
|
||||
peopleVectorWeight = rs.getDouble("people_vector_weight"),
|
||||
contentTypeVectorWeight = rs.getDouble("content_type_vector_weight"),
|
||||
updatedAt = rs.getTimestamp("updated_at").toLocalDateTime(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun findByUserId(userId: UUID): UserRecommendationWeights? =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT user_id,
|
||||
relevance_weight,
|
||||
quality_weight,
|
||||
context_weight,
|
||||
novelty_weight,
|
||||
diversity_weight,
|
||||
genre_vector_weight,
|
||||
plot_vector_weight,
|
||||
mood_vector_weight,
|
||||
era_vector_weight,
|
||||
people_vector_weight,
|
||||
content_type_vector_weight,
|
||||
updated_at
|
||||
FROM user_recommendation_weights
|
||||
WHERE user_id = ?
|
||||
""".trimIndent(),
|
||||
rowMapper,
|
||||
userId,
|
||||
).firstOrNull()
|
||||
|
||||
override fun save(weights: UserRecommendationWeights): UserRecommendationWeights {
|
||||
val normalized = weights.normalized(updatedAt = LocalDateTime.now())
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE user_recommendation_weights
|
||||
SET relevance_weight = ?,
|
||||
quality_weight = ?,
|
||||
context_weight = ?,
|
||||
novelty_weight = ?,
|
||||
diversity_weight = ?,
|
||||
genre_vector_weight = ?,
|
||||
plot_vector_weight = ?,
|
||||
mood_vector_weight = ?,
|
||||
era_vector_weight = ?,
|
||||
people_vector_weight = ?,
|
||||
content_type_vector_weight = ?,
|
||||
updated_at = ?
|
||||
WHERE user_id = ?
|
||||
""".trimIndent(),
|
||||
normalized.relevanceWeight,
|
||||
normalized.qualityWeight,
|
||||
normalized.contextWeight,
|
||||
normalized.noveltyWeight,
|
||||
normalized.diversityWeight,
|
||||
normalized.genreVectorWeight,
|
||||
normalized.plotVectorWeight,
|
||||
normalized.moodVectorWeight,
|
||||
normalized.eraVectorWeight,
|
||||
normalized.peopleVectorWeight,
|
||||
normalized.contentTypeVectorWeight,
|
||||
normalized.updatedAt,
|
||||
normalized.userId,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO user_recommendation_weights (
|
||||
user_id,
|
||||
relevance_weight,
|
||||
quality_weight,
|
||||
context_weight,
|
||||
novelty_weight,
|
||||
diversity_weight,
|
||||
genre_vector_weight,
|
||||
plot_vector_weight,
|
||||
mood_vector_weight,
|
||||
era_vector_weight,
|
||||
people_vector_weight,
|
||||
content_type_vector_weight,
|
||||
updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
normalized.userId,
|
||||
normalized.relevanceWeight,
|
||||
normalized.qualityWeight,
|
||||
normalized.contextWeight,
|
||||
normalized.noveltyWeight,
|
||||
normalized.diversityWeight,
|
||||
normalized.genreVectorWeight,
|
||||
normalized.plotVectorWeight,
|
||||
normalized.moodVectorWeight,
|
||||
normalized.eraVectorWeight,
|
||||
normalized.peopleVectorWeight,
|
||||
normalized.contentTypeVectorWeight,
|
||||
normalized.updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
@@ -22,36 +22,53 @@ class UserRepository(
|
||||
email = rs.getString("email"),
|
||||
provider = rs.getString("provider"),
|
||||
providerId = rs.getString("provider_id"),
|
||||
jellyfinUserId = rs.getString("jellyfin_user_id"),
|
||||
createdAt = rs.getTimestamp("created_at").toLocalDateTime(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun save(user: User): User {
|
||||
val entity = user.toEntity()
|
||||
val existingUser = findById(user.id)
|
||||
|
||||
val entity =
|
||||
if (existingUser != null) {
|
||||
val existingEntity = existingUser.toEntity()
|
||||
user.toEntity(
|
||||
provider = existingEntity.provider?.let { AuthProvider.valueOf(it) },
|
||||
providerId = existingEntity.providerId,
|
||||
createdAt = existingEntity.createdAt,
|
||||
)
|
||||
} else {
|
||||
user.toEntity()
|
||||
}
|
||||
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE users
|
||||
SET name = ?, email = ?, provider = ?, provider_id = ?
|
||||
SET name = ?, email = ?, provider = ?, provider_id = ?, jellyfin_user_id = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
entity.name,
|
||||
entity.email,
|
||||
entity.provider,
|
||||
entity.providerId,
|
||||
entity.jellyfinUserId,
|
||||
entity.id,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
"""
|
||||
INSERT INTO users (id, name, email, provider, provider_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, name, email, provider, provider_id, jellyfin_user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.email,
|
||||
entity.provider,
|
||||
entity.providerId,
|
||||
entity.jellyfinUserId,
|
||||
entity.createdAt,
|
||||
)
|
||||
}
|
||||
@@ -61,17 +78,27 @@ class UserRepository(
|
||||
override fun findById(id: UUID): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE id = ?",
|
||||
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users WHERE id = ?",
|
||||
userEntityRowMapper,
|
||||
id,
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
override fun findByEmail(email: String): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?",
|
||||
userEntityRowMapper,
|
||||
email,
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
override fun findAll(): List<User> =
|
||||
jdbc
|
||||
.query(
|
||||
"SELECT id, name, email, provider, provider_id, created_at FROM users",
|
||||
"SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users",
|
||||
userEntityRowMapper,
|
||||
).map { it.toDomain() }
|
||||
|
||||
@@ -86,7 +113,7 @@ class UserRepository(
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id, name, email, provider, provider_id, created_at FROM users
|
||||
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at FROM users
|
||||
WHERE provider = ? AND provider_id = ?
|
||||
""".trimIndent(),
|
||||
userEntityRowMapper,
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc.support
|
||||
|
||||
import java.net.URLDecoder
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object DelimitedValueCodec {
|
||||
fun encodeList(values: List<String>): String = values.joinToString("|") { encode(it) }
|
||||
|
||||
fun decodeList(value: String?): List<String> =
|
||||
value
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.split("|")
|
||||
?.map { decode(it) }
|
||||
?: emptyList()
|
||||
|
||||
fun encodeWeightedMap(values: Map<String, Int>): String =
|
||||
values.entries.joinToString("|") { entry -> "${encode(entry.key)}:${entry.value}" }
|
||||
|
||||
fun decodeWeightedMap(value: String?): Map<String, Int> {
|
||||
if (value.isNullOrBlank()) return emptyMap()
|
||||
|
||||
return value
|
||||
.split("|")
|
||||
.mapNotNull { pair ->
|
||||
val parts = pair.split(":", limit = 2)
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
|
||||
val key = decode(parts[0])
|
||||
val weight = parts[1].toIntOrNull() ?: return@mapNotNull null
|
||||
key to weight
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private fun encode(value: String): String = URLEncoder.encode(value, StandardCharsets.UTF_8)
|
||||
|
||||
private fun decode(value: String): String = URLDecoder.decode(value, StandardCharsets.UTF_8)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.domain.model.AuthProvider
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService
|
||||
import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class CustomOAuth2UserService(
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
) : DefaultOAuth2UserService() {
|
||||
companion object {
|
||||
private val log = LoggerFactory.getLogger(CustomOAuth2UserService::class.java)
|
||||
}
|
||||
|
||||
override fun loadUser(userRequest: OAuth2UserRequest): OAuth2User {
|
||||
val oAuth2User = super.loadUser(userRequest)
|
||||
val registrationId = userRequest.clientRegistration.registrationId
|
||||
|
||||
log.debug("Processing OAuth2 login for provider: {}", registrationId)
|
||||
|
||||
return try {
|
||||
val userInfo = OAuth2UserInfoFactory.getOAuth2UserInfo(registrationId, oAuth2User)
|
||||
val user = findOrCreateUser(userInfo)
|
||||
UserPrincipal.create(user, oAuth2User.attributes)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
log.error("OAuth2 authentication failed: ${e.message}", e)
|
||||
throw OAuth2AuthenticationException("Failed to process OAuth2 user data")
|
||||
} catch (e: OAuth2AuthenticationException) {
|
||||
log.error("OAuth2 authentication failed: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun findOrCreateUser(userInfo: OAuth2UserInfo): User {
|
||||
val provider = AuthProvider.valueOf(userInfo.getProvider().uppercase())
|
||||
|
||||
val existingUser =
|
||||
userRepository.findByProviderAndProviderId(
|
||||
provider,
|
||||
userInfo.getProviderId(),
|
||||
)
|
||||
|
||||
return if (existingUser != null) {
|
||||
log.debug("User found by provider: {}", userInfo.getProvider())
|
||||
existingUser
|
||||
} else {
|
||||
val userByEmail = userRepository.findByEmail(userInfo.getEmail())
|
||||
|
||||
if (userByEmail != null) {
|
||||
log.debug("Linking OAuth2 account to existing user: {}", userInfo.getEmail())
|
||||
val entity =
|
||||
userByEmail.toEntity(
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
userRepository.save(entity.toDomain())
|
||||
} else {
|
||||
log.debug("Creating new user for provider: {}", userInfo.getProvider())
|
||||
val newUser =
|
||||
User(
|
||||
id = idGenerator.generateId(),
|
||||
name = userInfo.getName(),
|
||||
email = userInfo.getEmail(),
|
||||
library = null,
|
||||
)
|
||||
val entity =
|
||||
newUser.toEntity(
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
userRepository.save(entity.toDomain())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
|
||||
class GoogleOAuth2UserInfo(
|
||||
private val attributes: Map<String, Any>,
|
||||
) : OAuth2UserInfo {
|
||||
override fun getProviderId(): String = attributes["sub"] as String
|
||||
|
||||
override fun getEmail(): String = attributes["email"] as String
|
||||
|
||||
override fun getName(): String = attributes["name"] as String
|
||||
|
||||
override fun getProvider(): String = "google"
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
|
||||
object OAuth2UserInfoFactory {
|
||||
fun getOAuth2UserInfo(
|
||||
registrationId: String,
|
||||
user: OAuth2User,
|
||||
): OAuth2UserInfo {
|
||||
val attributes = user.attributes
|
||||
|
||||
return when (registrationId.lowercase()) {
|
||||
"google" -> GoogleOAuth2UserInfo(attributes)
|
||||
"yandex" -> YandexOAuth2UserInfo(attributes)
|
||||
"vk" -> VkOAuth2UserInfo(attributes)
|
||||
else -> throw OAuth2AuthenticationException("Unknown provider: $registrationId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
class SecurityConfiguration(
|
||||
private val customOAuth2UserService: CustomOAuth2UserService,
|
||||
) {
|
||||
@Bean
|
||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
.oauth2Login { oauth2 ->
|
||||
oauth2
|
||||
.userInfoEndpoint { userInfo ->
|
||||
userInfo.userService(customOAuth2UserService)
|
||||
}.defaultSuccessUrl("/api/users/me", true)
|
||||
}.authorizeHttpRequests { auth ->
|
||||
auth
|
||||
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
|
||||
.permitAll()
|
||||
.requestMatchers("/api/users/me")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/**")
|
||||
.authenticated()
|
||||
.anyRequest()
|
||||
.authenticated()
|
||||
}.headers { headers ->
|
||||
headers.frameOptions { frameOptions ->
|
||||
frameOptions.sameOrigin()
|
||||
}
|
||||
}.csrf { csrf ->
|
||||
csrf.disable()
|
||||
}
|
||||
|
||||
return http.build()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.core.userdetails.UserDetails
|
||||
import org.springframework.security.oauth2.core.user.OAuth2User
|
||||
import java.util.UUID
|
||||
|
||||
class UserPrincipal(
|
||||
private val user: User,
|
||||
private val attributes: Map<String, Any>? = null,
|
||||
) : OAuth2User,
|
||||
UserDetails {
|
||||
fun getId(): UUID = user.id
|
||||
|
||||
override fun getName(): String = user.name
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes ?: emptyMap()
|
||||
|
||||
override fun getAuthorities(): Collection<GrantedAuthority> =
|
||||
listOf(
|
||||
SimpleGrantedAuthority("ROLE_USER"),
|
||||
)
|
||||
|
||||
override fun getPassword(): String = ""
|
||||
|
||||
override fun getUsername(): String = user.email
|
||||
|
||||
override fun isAccountNonExpired(): Boolean = true
|
||||
|
||||
override fun isAccountNonLocked(): Boolean = true
|
||||
|
||||
override fun isCredentialsNonExpired(): Boolean = true
|
||||
|
||||
override fun isEnabled(): Boolean = true
|
||||
|
||||
companion object {
|
||||
fun create(
|
||||
user: User,
|
||||
attributes: Map<String, Any>? = null,
|
||||
): UserPrincipal = UserPrincipal(user, attributes)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
|
||||
class VkOAuth2UserInfo(
|
||||
private val attributes: Map<String, Any>,
|
||||
) : OAuth2UserInfo {
|
||||
override fun getProviderId(): String =
|
||||
(attributes["response"] as? List<*>)
|
||||
?.firstOrNull()
|
||||
?.let { it as? Map<*, *> }
|
||||
?.get("id")
|
||||
?.toString() ?: ""
|
||||
|
||||
override fun getEmail(): String = attributes["email"]?.toString() ?: ""
|
||||
|
||||
override fun getName(): String {
|
||||
val response = attributes["response"] as? List<*>
|
||||
val first = response?.firstOrNull() as? Map<*, *>
|
||||
val firstName = first?.get("first_name")?.toString() ?: ""
|
||||
val lastName = first?.get("last_name")?.toString() ?: ""
|
||||
return "$firstName $lastName".trim()
|
||||
}
|
||||
|
||||
override fun getProvider(): String = "vk"
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.project.movienight.adapters.security
|
||||
|
||||
import com.project.movienight.application.ports.input.security.OAuth2UserInfo
|
||||
|
||||
class YandexOAuth2UserInfo(
|
||||
private val attributes: Map<String, Any>,
|
||||
) : OAuth2UserInfo {
|
||||
override fun getProviderId(): String = attributes["id"]?.toString() ?: ""
|
||||
|
||||
override fun getEmail(): String =
|
||||
(attributes["emails"] as? List<*>)
|
||||
?.firstOrNull()
|
||||
?.let { it as? Map<*, *> }
|
||||
?.get("value")
|
||||
?.toString() ?: ""
|
||||
|
||||
override fun getName(): String = attributes["display_name"]?.toString() ?: ""
|
||||
|
||||
override fun getProvider(): String = "yandex"
|
||||
|
||||
override fun getAttributes(): Map<String, Any> = attributes
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.project.movienight.adapters.web
|
||||
import com.project.movienight.domain.exception.BlockedValueException
|
||||
import com.project.movienight.domain.exception.DomainException
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
@@ -10,22 +12,60 @@ import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
|
||||
@RestControllerAdvice
|
||||
class ApiExceptionHandler {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@ExceptionHandler(EntityNotFoundException::class)
|
||||
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse =
|
||||
ErrorResponse(message = exception.message ?: "Entity not found")
|
||||
fun handleNotFound(exception: EntityNotFoundException): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.warn("Entity not found: traceId='{}', message='{}'", traceId, exception.message)
|
||||
|
||||
return ErrorResponse(
|
||||
message = exception.message ?: "Entity not found",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(BlockedValueException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse =
|
||||
ErrorResponse(message = exception.message ?: "Blocked value")
|
||||
fun handleBlockedValue(exception: BlockedValueException): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.warn("Blocked value: traceId='{}', message='{}'", traceId, exception.message)
|
||||
|
||||
return ErrorResponse(
|
||||
message = exception.message ?: "Blocked value",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(DomainException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun handleDomainException(exception: DomainException): ErrorResponse =
|
||||
ErrorResponse(message = exception.message ?: "Domain error")
|
||||
fun handleDomainException(exception: DomainException): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.warn("Domain error: traceId='{}', message='{}'", traceId, exception.message)
|
||||
|
||||
return ErrorResponse(
|
||||
message = exception.message ?: "Domain error",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
fun handleUnexpectedException(exception: Exception): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
log.error("Unexpected error: traceId='{}'", traceId, exception)
|
||||
|
||||
return ErrorResponse(
|
||||
message = "Internal server error",
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun currentTraceId(): String = MDC.get("traceId") ?: "unknown"
|
||||
}
|
||||
|
||||
data class ErrorResponse(
|
||||
val message: String,
|
||||
val traceId: String,
|
||||
)
|
||||
|
||||
@@ -8,23 +8,38 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase
|
||||
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
||||
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
|
||||
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
|
||||
import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PatchMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
private fun String.toContentTypeOrFilm(): com.project.movienight.domain.model.ContentType =
|
||||
runCatching {
|
||||
com.project.movienight.domain.model.ContentType
|
||||
.valueOf(this)
|
||||
}.getOrDefault(com.project.movienight.domain.model.ContentType.FILM)
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/films")
|
||||
class FilmController(
|
||||
private val createFilmUseCase: CreateFilmUseCase,
|
||||
private val editFilmUseCase: EditFilmUseCase,
|
||||
private val deleteFilmUseCase: DeleteFilmUseCase,
|
||||
private val getFilmByIdUseCase: GetFilmByIdUseCase,
|
||||
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||
private val searchFilmByTitleUseCase: SearchFilmByTitleUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@@ -36,6 +51,16 @@ class FilmController(
|
||||
CreateFilmCommand(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
contentType = request.contentType.toContentTypeOrFilm(),
|
||||
releaseYear = request.releaseYear,
|
||||
genres = request.genres,
|
||||
cast = request.cast,
|
||||
directors = request.directors,
|
||||
imdbRating = request.imdbRating,
|
||||
platformRating = request.platformRating,
|
||||
externalUrl = request.externalUrl,
|
||||
jellyfinItemId = request.jellyfinItemId,
|
||||
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -52,6 +77,16 @@ class FilmController(
|
||||
EditFilmCommand(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
contentType = request.contentType.toContentTypeOrFilm(),
|
||||
releaseYear = request.releaseYear,
|
||||
genres = request.genres,
|
||||
cast = request.cast,
|
||||
directors = request.directors,
|
||||
imdbRating = request.imdbRating,
|
||||
platformRating = request.platformRating,
|
||||
externalUrl = request.externalUrl,
|
||||
jellyfinItemId = request.jellyfinItemId,
|
||||
jellyfinLibraryId = request.jellyfinLibraryId,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -61,4 +96,24 @@ class FilmController(
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
) = deleteFilmUseCase.delete(id)
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getById(
|
||||
@PathVariable id: UUID,
|
||||
): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
|
||||
|
||||
@GetMapping
|
||||
fun getAll(): List<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||
|
||||
@GetMapping("/search")
|
||||
fun searchByTitle(
|
||||
@RequestParam title: String,
|
||||
): ResponseEntity<FilmResponse> {
|
||||
val film = searchFilmByTitleUseCase.searchByTitle(title)
|
||||
return if (film != null) {
|
||||
ResponseEntity.ok(FilmResponse.fromDomain(film))
|
||||
} else {
|
||||
ResponseEntity.notFound().build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,21 @@ package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.CreateFilmLibraryRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmLibraryResponse
|
||||
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
||||
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
||||
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
||||
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
|
||||
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
|
||||
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
||||
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
@@ -26,8 +33,11 @@ import java.util.UUID
|
||||
class FilmLibraryController(
|
||||
private val createFilmLibraryUseCase: CreateFilmLibraryUseCase,
|
||||
private val addFilmToLibraryUseCase: AddFilmToLibraryUseCase,
|
||||
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||
private val removeFilmFromLibraryUseCase: RemoveFilmFromLibraryUseCase,
|
||||
private val getFilmLibraryUseCase: GetFilmLibraryUseCase,
|
||||
private val getAllFilmsUseCase: GetAllFilmsUseCase,
|
||||
private val listFilmLibraryEntriesUseCase: ListFilmLibraryEntriesUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@@ -54,6 +64,11 @@ class FilmLibraryController(
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping("/entries")
|
||||
fun list(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
|
||||
|
||||
@PostMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun addFilm(
|
||||
@@ -69,6 +84,20 @@ class FilmLibraryController(
|
||||
),
|
||||
)
|
||||
|
||||
@PostMapping("/films/{filmId}/viewed")
|
||||
fun markViewed(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
markFilmViewedUseCase.markViewed(
|
||||
MarkFilmViewedCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@DeleteMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun removeFilm(
|
||||
@@ -82,4 +111,31 @@ class FilmLibraryController(
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@GetMapping("/available-films")
|
||||
fun getAvailableFilms(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmResponse> {
|
||||
val userLibrary =
|
||||
runCatching {
|
||||
getFilmLibraryUseCase.getLibrary(
|
||||
GetFilmLibraryQuery(userId = userId),
|
||||
)
|
||||
}.onFailure { exception ->
|
||||
if (exception !is EntityNotFoundException) {
|
||||
throw exception
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
val allFilms = getAllFilmsUseCase.getAll()
|
||||
|
||||
val availableFilms =
|
||||
if (userLibrary != null) {
|
||||
allFilms.filter { it.id != userLibrary.filmId }
|
||||
} else {
|
||||
allFilms
|
||||
}
|
||||
|
||||
return availableFilms.map { FilmResponse.fromDomain(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.RateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmRatingResponse
|
||||
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/ratings")
|
||||
class FilmRatingController(
|
||||
private val rateFilmUseCase: RateFilmUseCase,
|
||||
private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
|
||||
) {
|
||||
@PostMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun rate(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
@RequestBody request: RateFilmRequest,
|
||||
): FilmRatingResponse =
|
||||
FilmRatingResponse.fromDomain(
|
||||
rateFilmUseCase.rate(
|
||||
RateFilmCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
score = request.score,
|
||||
note = request.note,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun list(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
|
||||
import com.project.movienight.application.services.JellyfinEventService
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestHeader
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinEventsController(
|
||||
private val jellyfinEventService: JellyfinEventService,
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
|
||||
|
||||
@PostMapping("/events")
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
fun receiveEvent(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@RequestBody request: JellyfinEventRequest,
|
||||
) {
|
||||
if (!properties.enabled) {
|
||||
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
|
||||
}
|
||||
|
||||
if (properties.pluginToken.isNotBlank()) {
|
||||
if (token == null || token != properties.pluginToken) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||
}
|
||||
}
|
||||
|
||||
log.debug(
|
||||
"Received Jellyfin event {} for user {} item {}",
|
||||
request.eventId,
|
||||
request.jellyfinUserId,
|
||||
request.itemId,
|
||||
)
|
||||
jellyfinEventService.handleEvent(
|
||||
eventId = request.eventId,
|
||||
serverId = null,
|
||||
eventType = request.eventType,
|
||||
occurredAt = request.occurredAt,
|
||||
jellyfinUserId = request.jellyfinUserId,
|
||||
itemId = request.itemId,
|
||||
payload = request.payload,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.application.services.JellyfinSyncService
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinSyncController(
|
||||
private val jellyfinSyncService: JellyfinSyncService,
|
||||
) {
|
||||
@PostMapping("/sync")
|
||||
fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
|
||||
|
||||
@GetMapping("/sync-state")
|
||||
fun syncState(): List<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationEventResponse
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationResponse
|
||||
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
|
||||
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
|
||||
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||
import com.project.movienight.application.ports.input.RejectRecommendationCommand
|
||||
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RequestParam
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/recommendations")
|
||||
class RecommendationController(
|
||||
private val getRecommendationsUseCase: GetRecommendationsUseCase,
|
||||
private val acceptRecommendationUseCase: AcceptRecommendationUseCase,
|
||||
private val rejectRecommendationUseCase: RejectRecommendationUseCase,
|
||||
private val jellyfinProperties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
@GetMapping
|
||||
fun recommend(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestParam(required = false) contentType: String?,
|
||||
@RequestParam(required = false) mood: String?,
|
||||
@RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean,
|
||||
@RequestParam(required = false, defaultValue = "10") limit: Int,
|
||||
): List<RecommendationResponse> =
|
||||
getRecommendationsUseCase
|
||||
.recommend(
|
||||
RecommendationQuery(
|
||||
userId = userId,
|
||||
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
|
||||
mood = mood,
|
||||
libraryOnly = libraryOnly,
|
||||
limit = limit,
|
||||
),
|
||||
).map { recommendation ->
|
||||
RecommendationResponse.fromDomain(
|
||||
recommendation = recommendation,
|
||||
watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId),
|
||||
)
|
||||
}
|
||||
|
||||
@PostMapping("/{filmId}/accept")
|
||||
fun accept(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): RecommendationEventResponse =
|
||||
RecommendationEventResponse.fromDomain(
|
||||
acceptRecommendationUseCase.accept(
|
||||
AcceptRecommendationCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@PostMapping("/{filmId}/reject")
|
||||
fun reject(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): RecommendationEventResponse =
|
||||
RecommendationEventResponse.fromDomain(
|
||||
rejectRecommendationUseCase.reject(
|
||||
RejectRecommendationCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun buildWatchUrl(jellyfinItemId: String?): String? {
|
||||
if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val baseUrl = jellyfinProperties.webUrl.trimEnd('/')
|
||||
val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8)
|
||||
return "$baseUrl/web/#/details?id=$encodedItemId"
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest
|
||||
import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.RecommendationStyle
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/recommendation-onboarding")
|
||||
class RecommendationOnboardingController(
|
||||
private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
fun complete(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: RecommendationOnboardingRequest,
|
||||
): RecommendationOnboardingResponse =
|
||||
RecommendationOnboardingResponse.fromApplication(
|
||||
completeRecommendationOnboardingUseCase.complete(
|
||||
CompleteRecommendationOnboardingCommand(
|
||||
userId = userId,
|
||||
weightedGenres = request.weightedGenres,
|
||||
plotTypes = request.plotTypes,
|
||||
eras = request.eras,
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes = request.contentTypes.mapNotNull(::parseContentType),
|
||||
likedFilmIds = request.likedFilmIds,
|
||||
dislikedFilmIds = request.dislikedFilmIds,
|
||||
libraryFilmIds = request.libraryFilmIds,
|
||||
watchedFilmIds = request.watchedFilmIds,
|
||||
recommendationStyle = parseRecommendationStyle(request.recommendationStyle),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
private fun parseContentType(value: String): ContentType? =
|
||||
runCatching { ContentType.valueOf(value.uppercase(Locale.getDefault())) }.getOrNull()
|
||||
|
||||
private fun parseRecommendationStyle(value: String): RecommendationStyle =
|
||||
runCatching { RecommendationStyle.valueOf(value.uppercase(Locale.getDefault())) }
|
||||
.getOrDefault(RecommendationStyle.BALANCED)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import jakarta.servlet.FilterChain
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.filter.OncePerRequestFilter
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class TraceIdFilter : OncePerRequestFilter() {
|
||||
override fun doFilterInternal(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
filterChain: FilterChain,
|
||||
) {
|
||||
val traceId = UUID.randomUUID().toString()
|
||||
MDC.put("traceId", traceId)
|
||||
|
||||
try {
|
||||
filterChain.doFilter(request, response)
|
||||
} finally {
|
||||
MDC.remove("traceId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,11 @@ import com.project.movienight.application.ports.input.CreateUserUseCase
|
||||
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
||||
import com.project.movienight.application.ports.input.EditUserCommand
|
||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
||||
import com.project.movienight.application.ports.input.GetAllUsersUseCase
|
||||
import com.project.movienight.application.ports.input.GetUserByIdUseCase
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PatchMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
@@ -25,6 +28,8 @@ class UserController(
|
||||
private val createUserUseCase: CreateUserUseCase,
|
||||
private val editUserUseCase: EditUserUseCase,
|
||||
private val deleteUserUseCase: DeleteUserUseCase,
|
||||
private val getUserByIdUseCase: GetUserByIdUseCase,
|
||||
private val getAllUsersUseCase: GetAllUsersUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
@@ -40,6 +45,14 @@ class UserController(
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun getAll(): List<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getById(
|
||||
@PathVariable id: UUID,
|
||||
): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
fun edit(
|
||||
@PathVariable id: UUID,
|
||||
@@ -51,6 +64,7 @@ class UserController(
|
||||
command =
|
||||
EditUserCommand(
|
||||
name = request.name,
|
||||
jellyfinUserId = request.jellyfinUserId,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.UpsertUserPreferencesRequest
|
||||
import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse
|
||||
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/preferences")
|
||||
class UserPreferencesController(
|
||||
private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
|
||||
private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
|
||||
) {
|
||||
@PutMapping
|
||||
fun upsert(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: UpsertUserPreferencesRequest,
|
||||
): UserPreferencesResponse =
|
||||
UserPreferencesResponse.fromDomain(
|
||||
upsertUserPreferencesUseCase.upsert(
|
||||
UpsertUserPreferencesCommand(
|
||||
userId = userId,
|
||||
weightedGenres = request.weightedGenres,
|
||||
plotTypes = request.plotTypes,
|
||||
eras = request.eras,
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes =
|
||||
request.contentTypes.mapNotNull {
|
||||
runCatching {
|
||||
ContentType.valueOf(
|
||||
it,
|
||||
)
|
||||
}.getOrNull()
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun get(
|
||||
@PathVariable userId: UUID,
|
||||
): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.web.dto.request.UpdateUserRecommendationWeightsRequest
|
||||
import com.project.movienight.adapters.web.dto.response.UserRecommendationWeightsResponse
|
||||
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
|
||||
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
|
||||
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/recommendation-weights")
|
||||
class UserRecommendationWeightsController(
|
||||
private val getUserRecommendationWeightsUseCase: GetUserRecommendationWeightsUseCase,
|
||||
private val updateUserRecommendationWeightsUseCase: UpdateUserRecommendationWeightsUseCase,
|
||||
) {
|
||||
@GetMapping
|
||||
fun get(
|
||||
@PathVariable userId: UUID,
|
||||
): UserRecommendationWeightsResponse =
|
||||
UserRecommendationWeightsResponse.fromDomain(
|
||||
getUserRecommendationWeightsUseCase.get(userId),
|
||||
)
|
||||
|
||||
@PutMapping
|
||||
fun update(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: UpdateUserRecommendationWeightsRequest,
|
||||
): UserRecommendationWeightsResponse =
|
||||
UserRecommendationWeightsResponse.fromDomain(
|
||||
updateUserRecommendationWeightsUseCase.update(
|
||||
UpdateUserRecommendationWeightsCommand(
|
||||
userId = userId,
|
||||
relevanceWeight = request.relevanceWeight,
|
||||
qualityWeight = request.qualityWeight,
|
||||
contextWeight = request.contextWeight,
|
||||
noveltyWeight = request.noveltyWeight,
|
||||
diversityWeight = request.diversityWeight,
|
||||
genreVectorWeight = request.genreVectorWeight,
|
||||
plotVectorWeight = request.plotVectorWeight,
|
||||
moodVectorWeight = request.moodVectorWeight,
|
||||
eraVectorWeight = request.eraVectorWeight,
|
||||
peopleVectorWeight = request.peopleVectorWeight,
|
||||
contentTypeVectorWeight = request.contentTypeVectorWeight,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
|
||||
data class CreateFilmRequest(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val contentType: String = "FILM",
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
val imdbRating: Double? = null,
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
@@ -3,4 +3,14 @@ package com.project.movienight.adapters.web.dto.request
|
||||
data class EditFilmRequest(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val contentType: String = "FILM",
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
val imdbRating: Double? = null,
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
@@ -2,4 +2,5 @@ package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class EditUserRequest(
|
||||
val name: String,
|
||||
val jellyfinUserId: String? = null,
|
||||
)
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class JellyfinEventRequest(
|
||||
@JsonProperty("event_id")
|
||||
val eventId: String,
|
||||
@JsonProperty("event_type")
|
||||
val eventType: String,
|
||||
@JsonProperty("occurred_at")
|
||||
val occurredAt: OffsetDateTime,
|
||||
@JsonProperty("jellyfin_user_id")
|
||||
val jellyfinUserId: String,
|
||||
@JsonProperty("item_id")
|
||||
val itemId: String,
|
||||
@JsonProperty("payload_version")
|
||||
val payloadVersion: Int = 1,
|
||||
@JsonProperty("payload")
|
||||
val payload: Map<String, Any>? = null,
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class RateFilmRequest(
|
||||
val score: Int,
|
||||
val note: String? = null,
|
||||
)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class RecommendationOnboardingRequest(
|
||||
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||
val plotTypes: List<String> = emptyList(),
|
||||
val eras: List<String> = emptyList(),
|
||||
val castAndDirectors: List<String> = emptyList(),
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<String> = emptyList(),
|
||||
val likedFilmIds: List<UUID> = emptyList(),
|
||||
val dislikedFilmIds: List<UUID> = emptyList(),
|
||||
val libraryFilmIds: List<UUID> = emptyList(),
|
||||
val watchedFilmIds: List<UUID> = emptyList(),
|
||||
val recommendationStyle: String = "BALANCED",
|
||||
)
|
||||
+15
@@ -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,
|
||||
)
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class UpsertUserPreferencesRequest(
|
||||
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||
val plotTypes: List<String> = emptyList(),
|
||||
val eras: List<String> = emptyList(),
|
||||
val castAndDirectors: List<String> = emptyList(),
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<String> = emptyList(),
|
||||
)
|
||||
+2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class FilmRatingResponse(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val score: Int,
|
||||
val note: String?,
|
||||
val createdAt: LocalDateTime,
|
||||
val updatedAt: LocalDateTime,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(rating: FilmRating): FilmRatingResponse =
|
||||
FilmRatingResponse(
|
||||
id = rating.id,
|
||||
userId = rating.userId,
|
||||
filmId = rating.filmId,
|
||||
score = rating.score,
|
||||
note = rating.note,
|
||||
createdAt = rating.createdAt,
|
||||
updatedAt = rating.updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import java.util.UUID
|
||||
|
||||
@@ -7,6 +8,16 @@ data class FilmResponse(
|
||||
val id: UUID,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val contentType: ContentType,
|
||||
val releaseYear: Int?,
|
||||
val genres: List<String>,
|
||||
val cast: List<String>,
|
||||
val directors: List<String>,
|
||||
val imdbRating: Double?,
|
||||
val platformRating: Double?,
|
||||
val externalUrl: String?,
|
||||
val jellyfinItemId: String?,
|
||||
val jellyfinLibraryId: String?,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(film: Film): FilmResponse =
|
||||
@@ -14,6 +25,16 @@ data class FilmResponse(
|
||||
id = film.id,
|
||||
title = film.title,
|
||||
description = film.description,
|
||||
contentType = film.contentType,
|
||||
releaseYear = film.releaseYear,
|
||||
genres = film.genres,
|
||||
cast = film.cast,
|
||||
directors = film.directors,
|
||||
imdbRating = film.imdbRating,
|
||||
platformRating = film.platformRating,
|
||||
externalUrl = film.externalUrl,
|
||||
jellyfinItemId = film.jellyfinItemId,
|
||||
jellyfinLibraryId = film.jellyfinLibraryId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+37
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+27
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.RecommendationResult
|
||||
import java.util.UUID
|
||||
|
||||
data class RecommendationResponse(
|
||||
val filmId: UUID,
|
||||
val title: String,
|
||||
val score: Double,
|
||||
val reasons: List<String>,
|
||||
val jellyfinItemId: String?,
|
||||
val watchUrl: String?,
|
||||
val film: FilmResponse,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(
|
||||
recommendation: RecommendationResult,
|
||||
watchUrl: String?,
|
||||
): RecommendationResponse {
|
||||
val film = recommendation.film
|
||||
return RecommendationResponse(
|
||||
filmId = film.id,
|
||||
title = film.title,
|
||||
score = recommendation.score,
|
||||
reasons = recommendation.reasons,
|
||||
jellyfinItemId = film.jellyfinItemId,
|
||||
watchUrl = watchUrl,
|
||||
film = FilmResponse.fromDomain(film),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import java.util.UUID
|
||||
|
||||
data class UserPreferencesResponse(
|
||||
val userId: UUID,
|
||||
val weightedGenres: Map<String, Int>,
|
||||
val plotTypes: List<String>,
|
||||
val eras: List<String>,
|
||||
val castAndDirectors: List<String>,
|
||||
val moods: List<String>,
|
||||
val contentTypes: List<ContentType>,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(preferences: UserPreferences): UserPreferencesResponse =
|
||||
UserPreferencesResponse(
|
||||
userId = preferences.userId,
|
||||
weightedGenres = preferences.weightedGenres,
|
||||
plotTypes = preferences.plotTypes,
|
||||
eras = preferences.eras,
|
||||
castAndDirectors = preferences.castAndDirectors,
|
||||
moods = preferences.moods,
|
||||
contentTypes = preferences.contentTypes,
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class UserRecommendationWeightsResponse(
|
||||
val userId: UUID,
|
||||
val relevanceWeight: Double,
|
||||
val qualityWeight: Double,
|
||||
val contextWeight: Double,
|
||||
val noveltyWeight: Double,
|
||||
val diversityWeight: Double,
|
||||
val genreVectorWeight: Double,
|
||||
val plotVectorWeight: Double,
|
||||
val moodVectorWeight: Double,
|
||||
val eraVectorWeight: Double,
|
||||
val peopleVectorWeight: Double,
|
||||
val contentTypeVectorWeight: Double,
|
||||
val updatedAt: LocalDateTime,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(weights: UserRecommendationWeights): UserRecommendationWeightsResponse =
|
||||
UserRecommendationWeightsResponse(
|
||||
userId = weights.userId,
|
||||
relevanceWeight = weights.relevanceWeight,
|
||||
qualityWeight = weights.qualityWeight,
|
||||
contextWeight = weights.contextWeight,
|
||||
noveltyWeight = weights.noveltyWeight,
|
||||
diversityWeight = weights.diversityWeight,
|
||||
genreVectorWeight = weights.genreVectorWeight,
|
||||
plotVectorWeight = weights.plotVectorWeight,
|
||||
moodVectorWeight = weights.moodVectorWeight,
|
||||
eraVectorWeight = weights.eraVectorWeight,
|
||||
peopleVectorWeight = weights.peopleVectorWeight,
|
||||
contentTypeVectorWeight = weights.contentTypeVectorWeight,
|
||||
updatedAt = weights.updatedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ data class UserResponse(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val jellyfinUserId: String?,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(user: User): UserResponse =
|
||||
@@ -14,6 +15,7 @@ data class UserResponse(
|
||||
id = user.id,
|
||||
name = user.name,
|
||||
email = user.email,
|
||||
jellyfinUserId = user.jellyfinUserId,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
interface CreateFilmLibraryUseCase {
|
||||
@@ -21,6 +22,16 @@ data class AddFilmToLibraryCommand(
|
||||
val filmId: UUID,
|
||||
)
|
||||
|
||||
interface MarkFilmViewedUseCase {
|
||||
fun markViewed(command: MarkFilmViewedCommand): FilmLibrary
|
||||
}
|
||||
|
||||
data class MarkFilmViewedCommand(
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val watchedAt: LocalDateTime? = null,
|
||||
)
|
||||
|
||||
interface RemoveFilmFromLibraryUseCase {
|
||||
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary
|
||||
}
|
||||
@@ -38,3 +49,7 @@ interface GetFilmLibraryUseCase {
|
||||
data class GetFilmLibraryQuery(
|
||||
val userId: UUID,
|
||||
)
|
||||
|
||||
interface ListFilmLibraryEntriesUseCase {
|
||||
fun list(userId: UUID): List<FilmLibrary>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import java.util.UUID
|
||||
|
||||
interface RateFilmUseCase {
|
||||
fun rate(command: RateFilmCommand): FilmRating
|
||||
}
|
||||
|
||||
data class RateFilmCommand(
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val score: Int,
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
interface GetFilmRatingsUseCase {
|
||||
fun getRatings(userId: UUID): List<FilmRating>
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import java.util.UUID
|
||||
|
||||
@@ -10,6 +11,16 @@ interface CreateFilmUseCase {
|
||||
data class CreateFilmCommand(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val contentType: ContentType = ContentType.FILM,
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
val imdbRating: Double? = null,
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
interface EditFilmUseCase {
|
||||
@@ -22,8 +33,30 @@ interface EditFilmUseCase {
|
||||
data class EditFilmCommand(
|
||||
val title: String,
|
||||
val description: String,
|
||||
val contentType: ContentType = ContentType.FILM,
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
val imdbRating: Double? = null,
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
interface DeleteFilmUseCase {
|
||||
fun delete(id: UUID)
|
||||
}
|
||||
|
||||
interface GetFilmByIdUseCase {
|
||||
fun getById(id: UUID): Film
|
||||
}
|
||||
|
||||
interface GetAllFilmsUseCase {
|
||||
fun getAll(): List<Film>
|
||||
}
|
||||
|
||||
interface SearchFilmByTitleUseCase {
|
||||
fun searchByTitle(title: String): Film?
|
||||
}
|
||||
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.RecommendationEvent
|
||||
import com.project.movienight.domain.model.RecommendationResult
|
||||
import java.util.UUID
|
||||
|
||||
interface GetRecommendationsUseCase {
|
||||
fun recommend(query: RecommendationQuery): List<RecommendationResult>
|
||||
}
|
||||
|
||||
data class RecommendationQuery(
|
||||
val userId: UUID,
|
||||
val contentType: ContentType? = null,
|
||||
val mood: String? = null,
|
||||
val libraryOnly: Boolean = false,
|
||||
val limit: Int = 10,
|
||||
)
|
||||
|
||||
interface AcceptRecommendationUseCase {
|
||||
fun accept(command: AcceptRecommendationCommand): RecommendationEvent
|
||||
}
|
||||
|
||||
data class AcceptRecommendationCommand(
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
)
|
||||
|
||||
interface RejectRecommendationUseCase {
|
||||
fun reject(command: RejectRecommendationCommand): RecommendationEvent
|
||||
}
|
||||
|
||||
data class RejectRecommendationCommand(
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
)
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.RecommendationStyle
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import java.util.UUID
|
||||
|
||||
interface CompleteRecommendationOnboardingUseCase {
|
||||
fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult
|
||||
}
|
||||
|
||||
data class CompleteRecommendationOnboardingCommand(
|
||||
val userId: UUID,
|
||||
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||
val plotTypes: List<String> = emptyList(),
|
||||
val eras: List<String> = emptyList(),
|
||||
val castAndDirectors: List<String> = emptyList(),
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<ContentType> = emptyList(),
|
||||
val likedFilmIds: List<UUID> = emptyList(),
|
||||
val dislikedFilmIds: List<UUID> = emptyList(),
|
||||
val libraryFilmIds: List<UUID> = emptyList(),
|
||||
val watchedFilmIds: List<UUID> = emptyList(),
|
||||
val recommendationStyle: RecommendationStyle = RecommendationStyle.BALANCED,
|
||||
)
|
||||
|
||||
data class RecommendationOnboardingResult(
|
||||
val userId: UUID,
|
||||
val preferences: UserPreferences,
|
||||
val weights: UserRecommendationWeights,
|
||||
val likedFilmsCount: Int,
|
||||
val dislikedFilmsCount: Int,
|
||||
val libraryFilmsCount: Int,
|
||||
val watchedFilmsCount: Int,
|
||||
)
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import java.util.UUID
|
||||
|
||||
interface UpsertUserPreferencesUseCase {
|
||||
fun upsert(command: UpsertUserPreferencesCommand): UserPreferences
|
||||
}
|
||||
|
||||
data class UpsertUserPreferencesCommand(
|
||||
val userId: UUID,
|
||||
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||
val plotTypes: List<String> = emptyList(),
|
||||
val eras: List<String> = emptyList(),
|
||||
val castAndDirectors: List<String> = emptyList(),
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<ContentType> = emptyList(),
|
||||
)
|
||||
|
||||
interface GetUserPreferencesUseCase {
|
||||
fun get(userId: UUID): UserPreferences?
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import java.util.UUID
|
||||
|
||||
interface GetUserRecommendationWeightsUseCase {
|
||||
fun get(userId: UUID): UserRecommendationWeights
|
||||
}
|
||||
|
||||
interface UpdateUserRecommendationWeightsUseCase {
|
||||
fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights
|
||||
}
|
||||
|
||||
data class UpdateUserRecommendationWeightsCommand(
|
||||
val userId: UUID,
|
||||
val relevanceWeight: Double,
|
||||
val qualityWeight: Double,
|
||||
val contextWeight: Double,
|
||||
val noveltyWeight: Double,
|
||||
val diversityWeight: Double,
|
||||
val genreVectorWeight: Double,
|
||||
val plotVectorWeight: Double,
|
||||
val moodVectorWeight: Double,
|
||||
val eraVectorWeight: Double,
|
||||
val peopleVectorWeight: Double,
|
||||
val contentTypeVectorWeight: Double,
|
||||
)
|
||||
@@ -21,8 +21,17 @@ interface EditUserUseCase {
|
||||
|
||||
data class EditUserCommand(
|
||||
val name: String,
|
||||
val jellyfinUserId: String? = null,
|
||||
)
|
||||
|
||||
interface DeleteUserUseCase {
|
||||
fun delete(id: UUID)
|
||||
}
|
||||
|
||||
interface GetUserByIdUseCase {
|
||||
fun getById(id: UUID): User
|
||||
}
|
||||
|
||||
interface GetAllUsersUseCase {
|
||||
fun getAll(): List<User>
|
||||
}
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
package com.project.movienight.application.ports.input.security
|
||||
|
||||
interface OAuth2UserInfo {
|
||||
fun getProviderId(): String
|
||||
|
||||
fun getEmail(): String
|
||||
|
||||
fun getName(): String
|
||||
|
||||
fun getProvider(): String
|
||||
|
||||
fun getAttributes(): Map<String, Any>
|
||||
}
|
||||
+5
@@ -8,6 +8,11 @@ interface FilmLibraryRepositoryPort {
|
||||
|
||||
fun findById(id: UUID): FilmLibrary?
|
||||
|
||||
fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmLibrary?
|
||||
|
||||
fun findAll(): List<FilmLibrary>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import java.util.UUID
|
||||
|
||||
interface FilmRatingRepositoryPort {
|
||||
fun save(rating: FilmRating): FilmRating
|
||||
|
||||
fun findByUserId(userId: UUID): List<FilmRating>
|
||||
|
||||
fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmRating?
|
||||
}
|
||||
@@ -8,7 +8,13 @@ interface FilmRepositoryPort {
|
||||
|
||||
fun findById(id: UUID): Film?
|
||||
|
||||
fun findByJellyfinItemId(jellyfinItemId: String): Film?
|
||||
|
||||
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
|
||||
|
||||
fun findAll(): List<Film>
|
||||
|
||||
fun findByTitle(title: String): Film?
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
}
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import java.util.UUID
|
||||
|
||||
interface JellyfinSyncStateRepositoryPort {
|
||||
fun save(state: JellyfinSyncState): JellyfinSyncState
|
||||
|
||||
fun findByUserId(userId: UUID): JellyfinSyncState?
|
||||
|
||||
fun findAll(): List<JellyfinSyncState>
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.RecommendationEvent
|
||||
import java.util.UUID
|
||||
|
||||
interface RecommendationEventRepositoryPort {
|
||||
fun save(event: RecommendationEvent): RecommendationEvent
|
||||
|
||||
fun findByUserId(userId: UUID): List<RecommendationEvent>
|
||||
|
||||
fun findLatestRecommended(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): RecommendationEvent?
|
||||
}
|
||||
+10
@@ -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?
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import java.util.UUID
|
||||
|
||||
interface UserRecommendationWeightsRepositoryPort {
|
||||
fun findByUserId(userId: UUID): UserRecommendationWeights?
|
||||
|
||||
fun save(weights: UserRecommendationWeights): UserRecommendationWeights
|
||||
}
|
||||
@@ -9,6 +9,8 @@ interface UserRepositoryPort {
|
||||
|
||||
fun findById(id: UUID): User?
|
||||
|
||||
fun findByEmail(email: String): User?
|
||||
|
||||
fun findAll(): List<User>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||
import com.project.movienight.application.ports.input.AddFilmToLibraryCommand
|
||||
import com.project.movienight.application.ports.input.AddFilmToLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.CreateFilmLibraryCommand
|
||||
import com.project.movienight.application.ports.input.CreateFilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
||||
import com.project.movienight.application.ports.input.GetFilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.ListFilmLibraryEntriesUseCase
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryUseCase
|
||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||
@@ -20,70 +24,105 @@ import java.util.UUID
|
||||
class FilmLibraryService(
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) : CreateFilmLibraryUseCase,
|
||||
AddFilmToLibraryUseCase,
|
||||
MarkFilmViewedUseCase,
|
||||
RemoveFilmFromLibraryUseCase,
|
||||
GetFilmLibraryUseCase {
|
||||
GetFilmLibraryUseCase,
|
||||
ListFilmLibraryEntriesUseCase {
|
||||
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
|
||||
val existingLibrary = findByUserId(command.userId)
|
||||
if (existingLibrary != null) {
|
||||
return existingLibrary
|
||||
}
|
||||
|
||||
return filmLibraryRepository.save(
|
||||
FilmLibrary(
|
||||
id = idGenerator.generateId(),
|
||||
userId = command.userId,
|
||||
filmId = idGenerator.generateId(),
|
||||
comment = command.name,
|
||||
isViewed = false,
|
||||
),
|
||||
)
|
||||
findByUserId(command.userId)?.let { return it }
|
||||
throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
|
||||
}
|
||||
|
||||
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
|
||||
val existingLibrary = findByUserId(command.userId)
|
||||
if (existingLibrary == null) {
|
||||
return filmLibraryRepository.save(
|
||||
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
|
||||
if (existingEntry != null) {
|
||||
val saved =
|
||||
filmLibraryRepository.save(
|
||||
existingEntry.copy(
|
||||
isViewed = false,
|
||||
watchedAt = null,
|
||||
),
|
||||
)
|
||||
businessMetricsService.recordLibraryEvent()
|
||||
return saved
|
||||
}
|
||||
|
||||
val saved =
|
||||
filmLibraryRepository.save(
|
||||
FilmLibrary(
|
||||
id = idGenerator.generateId(),
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
comment = null,
|
||||
isViewed = false,
|
||||
watchedAt = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return filmLibraryRepository.save(
|
||||
existingLibrary.copy(
|
||||
filmId = command.filmId,
|
||||
isViewed = false,
|
||||
),
|
||||
)
|
||||
businessMetricsService.recordLibraryEvent()
|
||||
return saved
|
||||
}
|
||||
|
||||
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
|
||||
val existingLibrary =
|
||||
findByUserId(command.userId)
|
||||
?: throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
|
||||
if (command.libraryId != null) {
|
||||
filmLibraryRepository.findById(command.libraryId)
|
||||
?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
|
||||
} else {
|
||||
findByUserAndFilmId(command.userId, command.filmId)
|
||||
?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString())
|
||||
}
|
||||
|
||||
if (command.libraryId != null && command.libraryId != existingLibrary.id) {
|
||||
throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
|
||||
}
|
||||
|
||||
if (existingLibrary.filmId != command.filmId) {
|
||||
if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
|
||||
throw DomainException("Film with id ${command.filmId} not found in user's library")
|
||||
}
|
||||
|
||||
filmLibraryRepository.deleteById(existingLibrary.id)
|
||||
businessMetricsService.recordLibraryEvent()
|
||||
return existingLibrary
|
||||
}
|
||||
|
||||
override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
|
||||
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
|
||||
val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
|
||||
|
||||
val saved =
|
||||
if (existingEntry == null) {
|
||||
filmLibraryRepository.save(
|
||||
FilmLibrary(
|
||||
id = idGenerator.generateId(),
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
comment = null,
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
filmLibraryRepository.save(
|
||||
existingEntry.copy(
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
businessMetricsService.recordLibraryEvent()
|
||||
return saved
|
||||
}
|
||||
|
||||
override fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary =
|
||||
findByUserId(query.userId)
|
||||
?: throw EntityNotFoundException(entity = "Film library", id = query.userId.toString())
|
||||
|
||||
override fun list(userId: UUID): List<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
|
||||
|
||||
private fun findByUserId(userId: UUID): FilmLibrary? =
|
||||
filmLibraryRepository.findAll().firstOrNull { it.userId == userId }
|
||||
|
||||
private fun findByUserAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmLibrary? = filmLibraryRepository.findAll().firstOrNull { it.userId == userId && it.filmId == filmId }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||
import com.project.movienight.application.ports.input.GetFilmRatingsUseCase
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.domain.exception.DomainException
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class FilmRatingService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) : RateFilmUseCase,
|
||||
GetFilmRatingsUseCase {
|
||||
override fun rate(command: RateFilmCommand): FilmRating {
|
||||
if (command.score !in 1..10) {
|
||||
throw DomainException("Film rating score must be between 1 and 10")
|
||||
}
|
||||
|
||||
filmRepository.findById(command.filmId)
|
||||
?: throw EntityNotFoundException(entity = "Film", id = command.filmId.toString())
|
||||
|
||||
val existingRating = filmRatingRepository.findByUserIdAndFilmId(command.userId, command.filmId)
|
||||
val now = LocalDateTime.now()
|
||||
|
||||
val rating =
|
||||
if (existingRating == null) {
|
||||
FilmRating(
|
||||
id = idGenerator.generateId(),
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
score = command.score,
|
||||
note = command.note,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
)
|
||||
} else {
|
||||
existingRating.copy(score = command.score, note = command.note, updatedAt = now)
|
||||
}
|
||||
|
||||
val savedRating = filmRatingRepository.save(rating)
|
||||
businessMetricsService.recordRatingSubmitted()
|
||||
return savedRating
|
||||
}
|
||||
|
||||
override fun getRatings(userId: UUID): List<FilmRating> = filmRatingRepository.findByUserId(userId)
|
||||
}
|
||||
@@ -5,12 +5,19 @@ import com.project.movienight.application.ports.input.CreateFilmUseCase
|
||||
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||
import com.project.movienight.application.ports.input.EditFilmUseCase
|
||||
import com.project.movienight.application.ports.input.GetAllFilmsUseCase
|
||||
import com.project.movienight.application.ports.input.GetFilmByIdUseCase
|
||||
import com.project.movienight.application.ports.input.SearchFilmByTitleUseCase
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.config.FilmServiceProperties
|
||||
import com.project.movienight.domain.exception.BlockedValueException
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.Film
|
||||
import io.micrometer.core.instrument.Counter
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import io.micrometer.core.instrument.Timer
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
@@ -19,47 +26,181 @@ class FilmService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val filmConfig: FilmServiceProperties,
|
||||
private val meterRegistry: MeterRegistry,
|
||||
) : CreateFilmUseCase,
|
||||
EditFilmUseCase,
|
||||
DeleteFilmUseCase {
|
||||
override fun create(command: CreateFilmCommand): Film {
|
||||
if (filmConfig.isBlocked(command.title)) {
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
DeleteFilmUseCase,
|
||||
GetFilmByIdUseCase,
|
||||
GetAllFilmsUseCase,
|
||||
SearchFilmByTitleUseCase {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
val film =
|
||||
Film(
|
||||
id = idGenerator.generateId(),
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
override fun create(command: CreateFilmCommand): Film {
|
||||
val sample = Timer.start(meterRegistry)
|
||||
|
||||
try {
|
||||
log.debug(
|
||||
"Create film request received: title='{}', descriptionLength={}",
|
||||
command.title,
|
||||
command.description.length,
|
||||
)
|
||||
return filmRepository.save(film)
|
||||
|
||||
if (filmConfig.isBlocked(command.title)) {
|
||||
log.debug("Create film blocked by title policy: title='{}'", command.title)
|
||||
filmBlockedCounter.increment()
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
log.debug("Create film blocked by description policy")
|
||||
filmBlockedCounter.increment()
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
val film =
|
||||
Film(
|
||||
id = idGenerator.generateId(),
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
contentType = command.contentType,
|
||||
releaseYear = command.releaseYear,
|
||||
genres = command.genres,
|
||||
cast = command.cast,
|
||||
directors = command.directors,
|
||||
imdbRating = command.imdbRating,
|
||||
platformRating = command.platformRating,
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
val saved = filmRepository.save(film)
|
||||
filmCreatedCounter.increment()
|
||||
return saved
|
||||
} finally {
|
||||
sample.stop(createFilmTimer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun edit(
|
||||
id: UUID,
|
||||
command: EditFilmCommand,
|
||||
): Film {
|
||||
if (filmConfig.isBlocked(command.title)) {
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
val sample = Timer.start(meterRegistry)
|
||||
|
||||
try {
|
||||
log.debug("Edit film with id: {}", id)
|
||||
|
||||
if (filmConfig.isBlocked(command.title)) {
|
||||
log.debug("Edit film blocked by title policy: title='{}'", command.title)
|
||||
filmBlockedCounter.increment()
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
log.debug("Edit film blocked by description policy")
|
||||
filmBlockedCounter.increment()
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
var film = filmRepository.findById(id)
|
||||
|
||||
if (film == null) {
|
||||
log.debug("Film not found for edit: id='{}'", id)
|
||||
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
}
|
||||
|
||||
film =
|
||||
film.copy(
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
contentType = command.contentType,
|
||||
releaseYear = command.releaseYear,
|
||||
genres = command.genres,
|
||||
cast = command.cast,
|
||||
directors = command.directors,
|
||||
imdbRating = command.imdbRating,
|
||||
platformRating = command.platformRating,
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
val saved = filmRepository.save(film)
|
||||
filmEditedCounter.increment()
|
||||
return saved
|
||||
} finally {
|
||||
sample.stop(editFilmTimer)
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
var film = filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
|
||||
film = film.copy(title = command.title, description = command.description)
|
||||
|
||||
return filmRepository.save(film)
|
||||
}
|
||||
|
||||
override fun delete(id: UUID) {
|
||||
val sample = Timer.start(meterRegistry)
|
||||
|
||||
try {
|
||||
log.debug("Delete film with id: {}", id)
|
||||
|
||||
val film = filmRepository.findById(id)
|
||||
|
||||
if (film == null) {
|
||||
log.debug("Film not found for delete: id='{}'", id)
|
||||
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
}
|
||||
|
||||
filmRepository.deleteById(id)
|
||||
|
||||
filmDeletedCounter.increment()
|
||||
|
||||
log.info("Film deleted: id='{}'", id)
|
||||
} finally {
|
||||
sample.stop(deleteFilmTimer)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getById(id: UUID): Film =
|
||||
filmRepository.findById(id) ?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
|
||||
filmRepository.deleteById(id)
|
||||
}
|
||||
override fun getAll(): List<Film> = filmRepository.findAll()
|
||||
|
||||
override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title)
|
||||
|
||||
private val filmCreatedCounter =
|
||||
Counter
|
||||
.builder("film_created_total")
|
||||
.description("Total number of created films")
|
||||
.register(meterRegistry)
|
||||
|
||||
private val filmEditedCounter =
|
||||
Counter
|
||||
.builder("film_edited_total")
|
||||
.description("Total number of successfully edited films")
|
||||
.register(meterRegistry)
|
||||
|
||||
private val filmDeletedCounter =
|
||||
Counter
|
||||
.builder("film_deleted_total")
|
||||
.description("Total number of successfully deleted films")
|
||||
.register(meterRegistry)
|
||||
|
||||
private val filmBlockedCounter =
|
||||
Counter
|
||||
.builder("films.blocked")
|
||||
.description("Total blocked film operations")
|
||||
.register(meterRegistry)
|
||||
|
||||
private val createFilmTimer =
|
||||
Timer
|
||||
.builder("films.create.duration")
|
||||
.description("Film creation duration")
|
||||
.register(meterRegistry)
|
||||
|
||||
private val editFilmTimer =
|
||||
Timer
|
||||
.builder("films.edit.duration")
|
||||
.description("Film edit duration")
|
||||
.register(meterRegistry)
|
||||
|
||||
private val deleteFilmTimer =
|
||||
Timer
|
||||
.builder("films.delete.duration")
|
||||
.description("Film deletion duration")
|
||||
.register(meterRegistry)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||
import com.project.movienight.adapters.persistence.jdbc.JellyfinEventRepository
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
@Service
|
||||
class JellyfinEventService(
|
||||
private val jellyfinEventRepository: JellyfinEventRepository,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) {
|
||||
private val playbackEventTypes = setOf("playback.ended", "playback.stopped", "playback.completed")
|
||||
|
||||
fun handleEvent(
|
||||
eventId: String,
|
||||
serverId: String?,
|
||||
eventType: String,
|
||||
occurredAt: OffsetDateTime,
|
||||
jellyfinUserId: String,
|
||||
itemId: String,
|
||||
payload: Map<String, Any>?,
|
||||
) {
|
||||
val payloadJson = payload?.let { objectMapper.writeValueAsString(it) }
|
||||
val inserted =
|
||||
jellyfinEventRepository.save(
|
||||
eventId = eventId,
|
||||
serverId = serverId,
|
||||
eventType = eventType,
|
||||
occurredAt = occurredAt,
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
jellyfinItemId = itemId,
|
||||
payload = payloadJson,
|
||||
)
|
||||
if (inserted != 1) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (playbackEventTypes.contains(eventType)) {
|
||||
val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
|
||||
if (localUser == null) {
|
||||
jellyfinEventRepository.delete(eventId)
|
||||
businessMetricsService.recordJellyfinUnmappedUser()
|
||||
return
|
||||
}
|
||||
|
||||
val film = filmRepository.findByJellyfinItemId(itemId)
|
||||
if (film == null) {
|
||||
jellyfinEventRepository.delete(eventId)
|
||||
businessMetricsService.recordBackendWriteFailure()
|
||||
return
|
||||
}
|
||||
|
||||
markFilmViewedUseCase.markViewed(
|
||||
MarkFilmViewedCommand(
|
||||
userId = localUser.id,
|
||||
filmId = film.id,
|
||||
watchedAt = occurredAt.toLocalDateTime(),
|
||||
),
|
||||
)
|
||||
businessMetricsService.recordLibraryEvent()
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
|
||||
) {
|
||||
jellyfinEventRepository.delete(eventId)
|
||||
businessMetricsService.recordBackendWriteFailure()
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.adapters.jellyfin.JellyfinApiClient
|
||||
import com.project.movienight.adapters.jellyfin.JellyfinLibraryItemSnapshot
|
||||
import com.project.movienight.adapters.jellyfin.JellyfinRemoteUser
|
||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.JellyfinSyncStateRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
|
||||
@Service
|
||||
class JellyfinSyncService(
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) {
|
||||
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
|
||||
fun scheduledSync() {
|
||||
if (properties.enabled) {
|
||||
syncNow()
|
||||
}
|
||||
}
|
||||
|
||||
fun syncNow(): JellyfinSyncSummary {
|
||||
if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
|
||||
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
|
||||
}
|
||||
|
||||
val startedAt = Instant.now()
|
||||
val remoteUsers = jellyfinApiClient.fetchUsers()
|
||||
val localUsersByJellyfinId =
|
||||
userRepository
|
||||
.findAll()
|
||||
.mapNotNull { user ->
|
||||
user.jellyfinUserId?.let { it to user }
|
||||
}.toMap()
|
||||
|
||||
var syncedUsers = 0
|
||||
var skippedUsers = 0
|
||||
var syncedItems = 0
|
||||
|
||||
remoteUsers.forEach { remoteUser ->
|
||||
val localUser = localUsersByJellyfinId[remoteUser.id]
|
||||
if (localUser == null) {
|
||||
skippedUsers += 1
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id)
|
||||
items.forEach { item ->
|
||||
syncItem(localUser.id, item)
|
||||
syncedItems += 1
|
||||
}
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
syncStateRepository.save(
|
||||
JellyfinSyncState(
|
||||
userId = localUser.id,
|
||||
lastSyncedAt = now,
|
||||
lastSuccessfulSyncAt = now,
|
||||
lastError = null,
|
||||
syncedItemCount = items.size,
|
||||
),
|
||||
)
|
||||
syncedUsers += 1
|
||||
}
|
||||
|
||||
val summary =
|
||||
JellyfinSyncSummary(
|
||||
syncedUsers = syncedUsers,
|
||||
skippedUsers = skippedUsers,
|
||||
syncedItems = syncedItems,
|
||||
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
|
||||
)
|
||||
businessMetricsService.recordJellyfinSync(summary)
|
||||
return summary
|
||||
}
|
||||
|
||||
fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
|
||||
|
||||
private fun syncItem(
|
||||
userId: java.util.UUID,
|
||||
item: JellyfinLibraryItemSnapshot,
|
||||
) {
|
||||
val film =
|
||||
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
|
||||
title = item.title,
|
||||
description = item.description,
|
||||
contentType = item.contentType,
|
||||
releaseYear = item.releaseYear,
|
||||
genres = item.genres,
|
||||
cast = item.cast,
|
||||
directors = item.directors,
|
||||
imdbRating = item.imdbRating,
|
||||
platformRating = item.platformRating,
|
||||
externalUrl = item.externalUrl,
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
) ?: Film(
|
||||
id = idGenerator.generateId(),
|
||||
title = item.title,
|
||||
description = item.description,
|
||||
contentType = item.contentType,
|
||||
releaseYear = item.releaseYear,
|
||||
genres = item.genres,
|
||||
cast = item.cast,
|
||||
directors = item.directors,
|
||||
imdbRating = item.imdbRating,
|
||||
platformRating = item.platformRating,
|
||||
externalUrl = item.externalUrl,
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
val savedFilm = filmRepository.save(film)
|
||||
|
||||
if (item.isPlayed) {
|
||||
val watchedAt = LocalDateTime.now()
|
||||
val existingEntry = filmLibraryRepository.findByUserIdAndFilmId(userId, savedFilm.id)
|
||||
filmLibraryRepository.save(
|
||||
existingEntry?.copy(
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
) ?: FilmLibrary(
|
||||
id = idGenerator.generateId(),
|
||||
userId = userId,
|
||||
filmId = savedFilm.id,
|
||||
comment = null,
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand
|
||||
import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase
|
||||
import com.project.movienight.application.ports.input.RecommendationOnboardingResult
|
||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class RecommendationOnboardingService(
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
) : CompleteRecommendationOnboardingUseCase {
|
||||
override fun complete(command: CompleteRecommendationOnboardingCommand): RecommendationOnboardingResult {
|
||||
userRepository.findById(command.userId)
|
||||
?: throw EntityNotFoundException(entity = "User", id = command.userId.toString())
|
||||
|
||||
val filmIds =
|
||||
(
|
||||
command.likedFilmIds +
|
||||
command.dislikedFilmIds +
|
||||
command.libraryFilmIds +
|
||||
command.watchedFilmIds
|
||||
).distinct()
|
||||
ensureFilmsExist(filmIds)
|
||||
|
||||
val preferences =
|
||||
userPreferencesRepository.save(
|
||||
UserPreferences(
|
||||
userId = command.userId,
|
||||
weightedGenres = command.weightedGenres,
|
||||
plotTypes = command.plotTypes,
|
||||
eras = command.eras,
|
||||
castAndDirectors = command.castAndDirectors,
|
||||
moods = command.moods,
|
||||
contentTypes = command.contentTypes,
|
||||
),
|
||||
)
|
||||
|
||||
command.likedFilmIds.distinct().forEach { filmId ->
|
||||
saveRating(userId = command.userId, filmId = filmId, score = LIKED_SCORE, note = ONBOARDING_LIKED_NOTE)
|
||||
}
|
||||
command.dislikedFilmIds.distinct().forEach { filmId ->
|
||||
saveRating(
|
||||
userId = command.userId,
|
||||
filmId = filmId,
|
||||
score = DISLIKED_SCORE,
|
||||
note = ONBOARDING_DISLIKED_NOTE,
|
||||
)
|
||||
}
|
||||
command.libraryFilmIds.distinct().forEach { filmId ->
|
||||
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = false)
|
||||
}
|
||||
command.watchedFilmIds.distinct().forEach { filmId ->
|
||||
saveLibraryEntry(userId = command.userId, filmId = filmId, isViewed = true)
|
||||
}
|
||||
|
||||
val weights =
|
||||
userRecommendationWeightsRepository.save(
|
||||
UserRecommendationWeights.forStyle(
|
||||
userId = command.userId,
|
||||
style = command.recommendationStyle,
|
||||
),
|
||||
)
|
||||
|
||||
return RecommendationOnboardingResult(
|
||||
userId = command.userId,
|
||||
preferences = preferences,
|
||||
weights = weights,
|
||||
likedFilmsCount = command.likedFilmIds.distinct().size,
|
||||
dislikedFilmsCount = command.dislikedFilmIds.distinct().size,
|
||||
libraryFilmsCount = command.libraryFilmIds.distinct().size,
|
||||
watchedFilmsCount = command.watchedFilmIds.distinct().size,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureFilmsExist(filmIds: List<UUID>) {
|
||||
filmIds.forEach { filmId ->
|
||||
filmRepository.findById(filmId)
|
||||
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private fun saveRating(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
score: Int,
|
||||
note: String,
|
||||
): FilmRating {
|
||||
val now = LocalDateTime.now()
|
||||
val existing = filmRatingRepository.findByUserIdAndFilmId(userId, filmId)
|
||||
return filmRatingRepository.save(
|
||||
existing?.copy(score = score, note = note, updatedAt = now)
|
||||
?: FilmRating(
|
||||
id = idGenerator.generateId(),
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
score = score,
|
||||
note = note,
|
||||
createdAt = now,
|
||||
updatedAt = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun saveLibraryEntry(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
isViewed: Boolean,
|
||||
): FilmLibrary {
|
||||
val watchedAt = LocalDateTime.now().takeIf { isViewed }
|
||||
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
|
||||
return filmLibraryRepository.save(
|
||||
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
|
||||
?: FilmLibrary(
|
||||
id = idGenerator.generateId(),
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
comment = null,
|
||||
isViewed = isViewed,
|
||||
watchedAt = watchedAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val LIKED_SCORE = 10
|
||||
private const val DISLIKED_SCORE = 2
|
||||
private const val ONBOARDING_LIKED_NOTE = "Onboarding liked"
|
||||
private const val ONBOARDING_DISLIKED_NOTE = "Onboarding disliked"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,674 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.adapters.metrics.BusinessMetricsService
|
||||
import com.project.movienight.application.ports.input.AcceptRecommendationCommand
|
||||
import com.project.movienight.application.ports.input.AcceptRecommendationUseCase
|
||||
import com.project.movienight.application.ports.input.GetRecommendationsUseCase
|
||||
import com.project.movienight.application.ports.input.RecommendationQuery
|
||||
import com.project.movienight.application.ports.input.RejectRecommendationCommand
|
||||
import com.project.movienight.application.ports.input.RejectRecommendationUseCase
|
||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.RecommendationEventRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import com.project.movienight.domain.model.RecommendationEvent
|
||||
import com.project.movienight.domain.model.RecommendationEventType
|
||||
import com.project.movienight.domain.model.RecommendationResult
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.LocalDateTime
|
||||
import java.util.Locale
|
||||
import java.util.UUID
|
||||
import kotlin.math.sqrt
|
||||
|
||||
@Service
|
||||
class RecommendationService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val recommendationEventRepository: RecommendationEventRepositoryPort,
|
||||
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) : GetRecommendationsUseCase,
|
||||
AcceptRecommendationUseCase,
|
||||
RejectRecommendationUseCase {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
override fun recommend(query: RecommendationQuery): List<RecommendationResult> {
|
||||
businessMetricsService.recordRecommendationRequest()
|
||||
userRepository.findById(query.userId)
|
||||
?: throw EntityNotFoundException(entity = "User", id = query.userId.toString())
|
||||
|
||||
val preferences = userPreferencesRepository.findByUserId(query.userId)
|
||||
val ratings = filmRatingRepository.findByUserId(query.userId)
|
||||
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
|
||||
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
|
||||
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
|
||||
val films = filmRepository.findAll()
|
||||
val filmsById = films.associateBy { it.id }
|
||||
val weights = findWeights(query.userId)
|
||||
val userProfile = buildUserProfile(preferences, ratings, libraryEntries, filmsById, weights)
|
||||
|
||||
val candidates =
|
||||
films
|
||||
.asSequence()
|
||||
.filter { film -> query.contentType == null || film.contentType == query.contentType }
|
||||
.filter { film -> film.id !in watchedFilmIds }
|
||||
.filter { film -> !query.libraryOnly || film.id in libraryFilmIds }
|
||||
.toList()
|
||||
val scoredCandidates =
|
||||
candidates.map { film ->
|
||||
scoreFilm(film, query, preferences, userProfile, film.id in libraryFilmIds, weights)
|
||||
}
|
||||
val recommendationComparator =
|
||||
compareByDescending<ScoredRecommendation> { it.result.score }.thenBy {
|
||||
it.result.film.title
|
||||
}
|
||||
val scoredRecommendations =
|
||||
scoredCandidates
|
||||
.sortedWith(recommendationComparator)
|
||||
.take(query.limit.coerceAtLeast(1))
|
||||
|
||||
scoredRecommendations.forEach { recommendation ->
|
||||
saveEvent(
|
||||
userId = query.userId,
|
||||
filmId = recommendation.result.film.id,
|
||||
eventType = RecommendationEventType.RECOMMENDED,
|
||||
score = recommendation.result.score,
|
||||
relevanceScore = recommendation.relevanceScore,
|
||||
qualityScore = recommendation.qualityScore,
|
||||
contextScore = recommendation.contextScore,
|
||||
noveltyScore = recommendation.noveltyScore,
|
||||
diversityScore = recommendation.diversityScore,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(
|
||||
RECOMMENDATION_COMPLETED_LOG,
|
||||
query.userId,
|
||||
query.contentType,
|
||||
!query.mood.isNullOrBlank(),
|
||||
query.libraryOnly,
|
||||
query.limit,
|
||||
candidates.size,
|
||||
scoredRecommendations.size,
|
||||
)
|
||||
if (log.isDebugEnabled) {
|
||||
log.debug(
|
||||
"Recommendation top results: userId='{}', results='{}'",
|
||||
query.userId,
|
||||
scoredRecommendations.joinToString(separator = ",") { "${it.result.film.id}:${it.result.score}" },
|
||||
)
|
||||
}
|
||||
|
||||
return scoredRecommendations.map { it.result }
|
||||
}
|
||||
|
||||
override fun accept(command: AcceptRecommendationCommand): RecommendationEvent =
|
||||
saveFeedbackEvent(
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
eventType = RecommendationEventType.ACCEPTED,
|
||||
)
|
||||
|
||||
override fun reject(command: RejectRecommendationCommand): RecommendationEvent =
|
||||
saveFeedbackEvent(
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
eventType = RecommendationEventType.REJECTED,
|
||||
)
|
||||
|
||||
private fun saveFeedbackEvent(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
eventType: RecommendationEventType,
|
||||
): RecommendationEvent {
|
||||
userRepository.findById(userId)
|
||||
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||
filmRepository.findById(filmId)
|
||||
?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||
|
||||
val lastRecommendation = recommendationEventRepository.findLatestRecommended(userId, filmId)
|
||||
val event =
|
||||
saveEvent(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
eventType = eventType,
|
||||
score = lastRecommendation?.score,
|
||||
relevanceScore = lastRecommendation?.relevanceScore,
|
||||
qualityScore = lastRecommendation?.qualityScore,
|
||||
contextScore = lastRecommendation?.contextScore,
|
||||
noveltyScore = lastRecommendation?.noveltyScore,
|
||||
diversityScore = lastRecommendation?.diversityScore,
|
||||
)
|
||||
|
||||
if (lastRecommendation != null) {
|
||||
updateRecommendationWeights(
|
||||
userId = userId,
|
||||
eventType = eventType,
|
||||
recommendation = lastRecommendation,
|
||||
)
|
||||
} else {
|
||||
log.info(
|
||||
"Recommendation feedback saved without weight update: userId='{}', filmId='{}', eventType='{}'",
|
||||
userId,
|
||||
filmId,
|
||||
eventType,
|
||||
)
|
||||
}
|
||||
|
||||
log.info(
|
||||
RECOMMENDATION_FEEDBACK_SAVED_LOG,
|
||||
userId,
|
||||
filmId,
|
||||
eventType,
|
||||
)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
private fun saveEvent(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
eventType: RecommendationEventType,
|
||||
score: Double?,
|
||||
relevanceScore: Double? = null,
|
||||
qualityScore: Double? = null,
|
||||
contextScore: Double? = null,
|
||||
noveltyScore: Double? = null,
|
||||
diversityScore: Double? = null,
|
||||
): RecommendationEvent =
|
||||
recommendationEventRepository.save(
|
||||
RecommendationEvent(
|
||||
id = idGenerator.generateId(),
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
eventType = eventType,
|
||||
score = score,
|
||||
relevanceScore = relevanceScore,
|
||||
qualityScore = qualityScore,
|
||||
contextScore = contextScore,
|
||||
noveltyScore = noveltyScore,
|
||||
diversityScore = diversityScore,
|
||||
createdAt = LocalDateTime.now(),
|
||||
),
|
||||
)
|
||||
|
||||
private fun findWeights(userId: UUID): UserRecommendationWeights =
|
||||
(
|
||||
userRecommendationWeightsRepository.findByUserId(userId)
|
||||
?: UserRecommendationWeights.defaultFor(userId)
|
||||
).normalized()
|
||||
|
||||
private fun updateRecommendationWeights(
|
||||
userId: UUID,
|
||||
eventType: RecommendationEventType,
|
||||
recommendation: RecommendationEvent,
|
||||
) {
|
||||
val current = findWeights(userId)
|
||||
val contributions = scoreContributions(recommendation, current) ?: return
|
||||
val direction =
|
||||
when (eventType) {
|
||||
RecommendationEventType.ACCEPTED -> 1.0
|
||||
RecommendationEventType.REJECTED -> -1.0
|
||||
RecommendationEventType.RECOMMENDED -> return
|
||||
}
|
||||
|
||||
val updated =
|
||||
current
|
||||
.copy(
|
||||
relevanceWeight = current.relevanceWeight + direction * LEARNING_RATE * contributions.relevance,
|
||||
qualityWeight = current.qualityWeight + direction * LEARNING_RATE * contributions.quality,
|
||||
contextWeight = current.contextWeight + direction * LEARNING_RATE * contributions.context,
|
||||
noveltyWeight = current.noveltyWeight + direction * LEARNING_RATE * contributions.novelty,
|
||||
diversityWeight = current.diversityWeight + direction * LEARNING_RATE * contributions.diversity,
|
||||
).normalized(updatedAt = LocalDateTime.now())
|
||||
|
||||
val saved = userRecommendationWeightsRepository.save(updated)
|
||||
businessMetricsService.recordRecommendationWeightsUpdated(eventType)
|
||||
log.info(
|
||||
RECOMMENDATION_WEIGHTS_UPDATED_LOG,
|
||||
userId,
|
||||
eventType,
|
||||
current.hashCode(),
|
||||
saved.hashCode(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun scoreContributions(
|
||||
recommendation: RecommendationEvent,
|
||||
weights: UserRecommendationWeights,
|
||||
): ScoreContributions? {
|
||||
val rawContributions =
|
||||
listOf(
|
||||
weights.relevanceWeight to recommendation.relevanceScore,
|
||||
weights.qualityWeight to recommendation.qualityScore,
|
||||
weights.contextWeight to recommendation.contextScore,
|
||||
weights.noveltyWeight to recommendation.noveltyScore,
|
||||
weights.diversityWeight to recommendation.diversityScore,
|
||||
).map { (weight, score) ->
|
||||
weight * (score?.takeIf { value -> value.isFinite() }?.coerceAtLeast(0.0) ?: 0.0)
|
||||
}
|
||||
val total = rawContributions.sum()
|
||||
if (total <= 0.0) {
|
||||
return null
|
||||
}
|
||||
return ScoreContributions(
|
||||
relevance = rawContributions[0] / total,
|
||||
quality = rawContributions[1] / total,
|
||||
context = rawContributions[2] / total,
|
||||
novelty = rawContributions[3] / total,
|
||||
diversity = rawContributions[4] / total,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildUserProfile(
|
||||
preferences: UserPreferences?,
|
||||
ratings: List<FilmRating>,
|
||||
libraryEntries: List<FilmLibrary>,
|
||||
filmsById: Map<UUID, Film>,
|
||||
weights: UserRecommendationWeights,
|
||||
): SparseVector {
|
||||
val profile = MutableSparseVector()
|
||||
|
||||
preferences?.weightedGenres.orEmpty().forEach { (genre, weight) ->
|
||||
profile.add(feature("genre", genre), weight.coerceAtLeast(1).toDouble() / MAX_PREFERENCE_WEIGHT)
|
||||
}
|
||||
preferences?.plotTypes.orEmpty().forEach { plotType ->
|
||||
tokenize(plotType).forEach { profile.add(feature("plot", it), PREFERENCE_PLOT_WEIGHT) }
|
||||
}
|
||||
preferences?.eras.orEmpty().forEach { profile.add(feature("era", it), PREFERENCE_ERA_WEIGHT) }
|
||||
preferences?.castAndDirectors.orEmpty().forEach { profile.add(feature("person", it), PREFERENCE_PERSON_WEIGHT) }
|
||||
preferences?.moods.orEmpty().forEach { profile.add(feature("mood", it), PREFERENCE_MOOD_WEIGHT) }
|
||||
preferences
|
||||
?.contentTypes
|
||||
.orEmpty()
|
||||
.forEach {
|
||||
profile.add(
|
||||
feature("type", it.name),
|
||||
PREFERENCE_CONTENT_TYPE_WEIGHT,
|
||||
)
|
||||
}
|
||||
|
||||
ratings.forEach { rating ->
|
||||
val film = filmsById[rating.filmId] ?: return@forEach
|
||||
val signal = ratingSignal(rating.score)
|
||||
profile.add(buildFilmVector(film, weights).scale(signal))
|
||||
}
|
||||
|
||||
libraryEntries.filterNot { it.isViewed }.forEach { entry ->
|
||||
val film = filmsById[entry.filmId] ?: return@forEach
|
||||
profile.add(buildFilmVector(film, weights).scale(LIBRARY_SIGNAL_WEIGHT))
|
||||
}
|
||||
|
||||
return profile.toSparseVector()
|
||||
}
|
||||
|
||||
private fun scoreFilm(
|
||||
film: Film,
|
||||
query: RecommendationQuery,
|
||||
preferences: UserPreferences?,
|
||||
userProfile: SparseVector,
|
||||
inLibrary: Boolean,
|
||||
weights: UserRecommendationWeights,
|
||||
): ScoredRecommendation {
|
||||
val reasons = mutableListOf<String>()
|
||||
val filmVector = buildFilmVector(film, weights)
|
||||
val preferenceScore = cosineSimilarity(userProfile, filmVector)
|
||||
val qualityScore = qualityScore(film)
|
||||
val contextScore = contextScore(film, query, preferences)
|
||||
val noveltyScore = if (inLibrary) LIBRARY_NOVELTY_SCORE else CATALOG_NOVELTY_SCORE
|
||||
val diversityScore = diversityScore(film, preferences)
|
||||
val score =
|
||||
weights.relevanceWeight * preferenceScore +
|
||||
weights.qualityWeight * qualityScore +
|
||||
weights.contextWeight * contextScore +
|
||||
weights.noveltyWeight * noveltyScore +
|
||||
weights.diversityWeight * diversityScore
|
||||
|
||||
if (preferenceScore > STRONG_REASON_THRESHOLD) {
|
||||
reasons += "Similar to user preferences and rating history"
|
||||
}
|
||||
matchingGenres(film, preferences).take(MAX_REASON_ITEMS).forEach { genre ->
|
||||
reasons += "Matches preferred genre: $genre"
|
||||
}
|
||||
matchingPeople(film, preferences).take(MAX_REASON_ITEMS).forEach { person ->
|
||||
reasons += "Matches preferred cast or director: $person"
|
||||
}
|
||||
query.mood?.takeIf { inferredMoods(film).contains(normalize(it)) }?.let { mood ->
|
||||
reasons += "Matches requested mood: $mood"
|
||||
}
|
||||
film.releaseYear?.let { year ->
|
||||
if (preferences?.eras.orEmpty().any { normalize(it) == normalize(decadeOf(year)) }) {
|
||||
reasons += "Matches preferred era: ${decadeOf(year)}"
|
||||
}
|
||||
}
|
||||
if (qualityScore >= QUALITY_REASON_THRESHOLD) {
|
||||
reasons += "High rating signal"
|
||||
}
|
||||
if (inLibrary) {
|
||||
reasons += "Already in user library"
|
||||
}
|
||||
|
||||
if (reasons.isEmpty()) {
|
||||
reasons += "Baseline recommendation from catalog quality"
|
||||
}
|
||||
|
||||
return ScoredRecommendation(
|
||||
result = RecommendationResult(film = film, score = roundScore(score), reasons = reasons.distinct()),
|
||||
relevanceScore = preferenceScore,
|
||||
qualityScore = qualityScore,
|
||||
contextScore = contextScore,
|
||||
noveltyScore = noveltyScore,
|
||||
diversityScore = diversityScore,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildFilmVector(
|
||||
film: Film,
|
||||
weights: UserRecommendationWeights,
|
||||
): SparseVector {
|
||||
val vector = MutableSparseVector()
|
||||
val normalizedGenres = film.genres.map(::normalize).filter { it.isNotBlank() }
|
||||
val plotTokens = tokenize("${film.title} ${film.description}")
|
||||
val moods = inferredMoods(film)
|
||||
val people = (film.directors + film.cast).map(::normalize).filter { it.isNotBlank() }
|
||||
|
||||
vector.add(feature("type", film.contentType.name), weights.contentTypeVectorWeight)
|
||||
distribute(vector, "genre", normalizedGenres, weights.genreVectorWeight)
|
||||
distribute(vector, "plot", plotTokens, weights.plotVectorWeight)
|
||||
distribute(vector, "mood", moods, weights.moodVectorWeight)
|
||||
film.releaseYear?.let { vector.add(feature("era", decadeOf(it)), weights.eraVectorWeight) }
|
||||
distribute(vector, "person", people, weights.peopleVectorWeight)
|
||||
|
||||
return vector.toSparseVector()
|
||||
}
|
||||
|
||||
private fun contextScore(
|
||||
film: Film,
|
||||
query: RecommendationQuery,
|
||||
preferences: UserPreferences?,
|
||||
): Double {
|
||||
var score = 0.0
|
||||
var checks = 0
|
||||
|
||||
query.mood?.let {
|
||||
checks += 1
|
||||
if (inferredMoods(film).contains(normalize(it))) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
preferences?.contentTypes?.takeIf { it.isNotEmpty() }?.let {
|
||||
checks += 1
|
||||
if (film.contentType in it) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
preferences?.eras?.takeIf { it.isNotEmpty() }?.let { eras ->
|
||||
film.releaseYear?.let {
|
||||
checks += 1
|
||||
if (eras.any { era -> normalize(era) == normalize(decadeOf(it)) }) {
|
||||
score += 1.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return if (checks == 0) BASE_CONTEXT_SCORE else score / checks
|
||||
}
|
||||
|
||||
private fun qualityScore(film: Film): Double {
|
||||
val normalizedRatings =
|
||||
listOfNotNull(
|
||||
film.imdbRating?.let { normalizeRating(it) },
|
||||
film.platformRating?.let { normalizeRating(it) },
|
||||
)
|
||||
return normalizedRatings.averageOrNull() ?: BASE_QUALITY_SCORE
|
||||
}
|
||||
|
||||
private fun diversityScore(
|
||||
film: Film,
|
||||
preferences: UserPreferences?,
|
||||
): Double {
|
||||
val preferredGenres =
|
||||
preferences
|
||||
?.weightedGenres
|
||||
.orEmpty()
|
||||
.keys
|
||||
.map(::normalize)
|
||||
.toSet()
|
||||
val filmGenres = film.genres.map(::normalize).toSet()
|
||||
return when {
|
||||
preferredGenres.isEmpty() -> BASE_DIVERSITY_SCORE
|
||||
filmGenres.none { it in preferredGenres } -> HIGH_DIVERSITY_SCORE
|
||||
filmGenres.size > 1 -> MEDIUM_DIVERSITY_SCORE
|
||||
else -> LOW_DIVERSITY_SCORE
|
||||
}
|
||||
}
|
||||
|
||||
private fun inferredMoods(film: Film): Set<String> {
|
||||
val text = normalize("${film.title} ${film.description} ${film.genres.joinToString(" ")}")
|
||||
return moodLexicon
|
||||
.filterValues { keywords -> keywords.any { keyword -> text.contains(keyword) } }
|
||||
.keys
|
||||
}
|
||||
|
||||
private fun matchingGenres(
|
||||
film: Film,
|
||||
preferences: UserPreferences?,
|
||||
): List<String> {
|
||||
val filmGenres = film.genres.associateBy { normalize(it) }
|
||||
return preferences
|
||||
?.weightedGenres
|
||||
.orEmpty()
|
||||
.keys
|
||||
.map(::normalize)
|
||||
.mapNotNull { filmGenres[it] }
|
||||
}
|
||||
|
||||
private fun matchingPeople(
|
||||
film: Film,
|
||||
preferences: UserPreferences?,
|
||||
): List<String> {
|
||||
val people = (film.cast + film.directors).associateBy { normalize(it) }
|
||||
return preferences
|
||||
?.castAndDirectors
|
||||
.orEmpty()
|
||||
.map(::normalize)
|
||||
.mapNotNull { people[it] }
|
||||
}
|
||||
|
||||
private fun distribute(
|
||||
vector: MutableSparseVector,
|
||||
namespace: String,
|
||||
values: Collection<String>,
|
||||
totalWeight: Double,
|
||||
) {
|
||||
val uniqueValues = values.map(::normalize).filter { it.isNotBlank() }.distinct()
|
||||
if (uniqueValues.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val itemWeight = totalWeight / uniqueValues.size
|
||||
uniqueValues.forEach { vector.add(feature(namespace, it), itemWeight) }
|
||||
}
|
||||
|
||||
private fun ratingSignal(score: Int): Double =
|
||||
when (score.coerceIn(MIN_USER_RATING, MAX_USER_RATING)) {
|
||||
10 -> 1.0
|
||||
9 -> 0.9
|
||||
8 -> 0.7
|
||||
7 -> 0.4
|
||||
6 -> 0.1
|
||||
5 -> 0.0
|
||||
4 -> -0.3
|
||||
3 -> -0.5
|
||||
2 -> -0.8
|
||||
else -> -1.0
|
||||
}
|
||||
|
||||
private fun normalizeRating(rating: Double): Double = (rating / MAX_RATING_VALUE).coerceIn(0.0, 1.0)
|
||||
|
||||
private fun decadeOf(year: Int): String = "${year / 10 * 10}s"
|
||||
|
||||
private fun tokenize(text: String): List<String> =
|
||||
normalize(text)
|
||||
.split(tokenSeparatorRegex)
|
||||
.asSequence()
|
||||
.filter { it.length >= MIN_TOKEN_LENGTH }
|
||||
.filterNot { it in stopWords }
|
||||
.distinct()
|
||||
.toList()
|
||||
|
||||
private fun feature(
|
||||
namespace: String,
|
||||
value: String,
|
||||
): String = "$namespace:${normalize(value)}"
|
||||
|
||||
private fun normalize(value: String): String =
|
||||
value
|
||||
.trim()
|
||||
.lowercase(Locale.getDefault())
|
||||
|
||||
private fun cosineSimilarity(
|
||||
left: SparseVector,
|
||||
right: SparseVector,
|
||||
): Double {
|
||||
if (left.values.isEmpty() || right.values.isEmpty()) {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
val dot =
|
||||
left.values
|
||||
.entries
|
||||
.sumOf { (feature, weight) -> weight * (right.values[feature] ?: 0.0) }
|
||||
val leftNorm = sqrt(left.values.values.sumOf { it * it })
|
||||
val rightNorm = sqrt(right.values.values.sumOf { it * it })
|
||||
if (leftNorm == 0.0 || rightNorm == 0.0) {
|
||||
return 0.0
|
||||
}
|
||||
|
||||
return dot / (leftNorm * rightNorm)
|
||||
}
|
||||
|
||||
private fun roundScore(score: Double): Double =
|
||||
kotlin.math.round(score * SCORE_ROUNDING_FACTOR) / SCORE_ROUNDING_FACTOR
|
||||
|
||||
private fun Iterable<Double>.averageOrNull(): Double? {
|
||||
val values = toList()
|
||||
return values.takeIf { it.isNotEmpty() }?.average()
|
||||
}
|
||||
|
||||
private data class ScoredRecommendation(
|
||||
val result: RecommendationResult,
|
||||
val relevanceScore: Double,
|
||||
val qualityScore: Double,
|
||||
val contextScore: Double,
|
||||
val noveltyScore: Double,
|
||||
val diversityScore: Double,
|
||||
)
|
||||
|
||||
private data class ScoreContributions(
|
||||
val relevance: Double,
|
||||
val quality: Double,
|
||||
val context: Double,
|
||||
val novelty: Double,
|
||||
val diversity: Double,
|
||||
)
|
||||
|
||||
private data class SparseVector(
|
||||
val values: Map<String, Double>,
|
||||
) {
|
||||
fun scale(weight: Double): SparseVector = SparseVector(values.mapValues { it.value * weight })
|
||||
}
|
||||
|
||||
private class MutableSparseVector {
|
||||
private val values = mutableMapOf<String, Double>()
|
||||
|
||||
fun add(
|
||||
feature: String,
|
||||
weight: Double,
|
||||
) {
|
||||
if (weight == 0.0) {
|
||||
return
|
||||
}
|
||||
values[feature] = (values[feature] ?: 0.0) + weight
|
||||
}
|
||||
|
||||
fun add(vector: SparseVector) {
|
||||
vector.values.forEach { (feature, weight) -> add(feature, weight) }
|
||||
}
|
||||
|
||||
fun toSparseVector(): SparseVector = SparseVector(values.filterValues { it != 0.0 })
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val RECOMMENDATION_COMPLETED_LOG =
|
||||
"Recommendation request completed: userId='{}', contentType='{}', moodPresent={}, " +
|
||||
"libraryOnly={}, limit={}, candidatesCount={}, returnedCount={}"
|
||||
private const val RECOMMENDATION_FEEDBACK_SAVED_LOG =
|
||||
"Recommendation feedback saved: userId='{}', filmId='{}', eventType='{}'"
|
||||
private const val RECOMMENDATION_WEIGHTS_UPDATED_LOG =
|
||||
"Recommendation weights updated: userId='{}', eventType='{}', oldWeightsHash={}, newWeightsHash={}"
|
||||
|
||||
private const val MAX_PREFERENCE_WEIGHT = 5.0
|
||||
private const val MAX_RATING_VALUE = 10.0
|
||||
private const val MIN_USER_RATING = 1
|
||||
private const val MAX_USER_RATING = 10
|
||||
private const val MIN_TOKEN_LENGTH = 3
|
||||
private const val MAX_REASON_ITEMS = 2
|
||||
private const val SCORE_ROUNDING_FACTOR = 1000.0
|
||||
|
||||
private const val PREFERENCE_PLOT_WEIGHT = 0.6
|
||||
private const val PREFERENCE_ERA_WEIGHT = 0.7
|
||||
private const val PREFERENCE_PERSON_WEIGHT = 0.8
|
||||
private const val PREFERENCE_MOOD_WEIGHT = 0.8
|
||||
private const val PREFERENCE_CONTENT_TYPE_WEIGHT = 0.5
|
||||
private const val LIBRARY_SIGNAL_WEIGHT = 0.25
|
||||
|
||||
private const val LEARNING_RATE = 0.03
|
||||
|
||||
private const val LIBRARY_NOVELTY_SCORE = 0.85
|
||||
private const val CATALOG_NOVELTY_SCORE = 0.65
|
||||
private const val BASE_CONTEXT_SCORE = 0.5
|
||||
private const val BASE_QUALITY_SCORE = 0.5
|
||||
private const val BASE_DIVERSITY_SCORE = 0.5
|
||||
private const val HIGH_DIVERSITY_SCORE = 1.0
|
||||
private const val MEDIUM_DIVERSITY_SCORE = 0.6
|
||||
private const val LOW_DIVERSITY_SCORE = 0.3
|
||||
private const val STRONG_REASON_THRESHOLD = 0.15
|
||||
private const val QUALITY_REASON_THRESHOLD = 0.75
|
||||
|
||||
private val tokenSeparatorRegex = Regex("[^\\p{L}0-9]+")
|
||||
private val stopWords =
|
||||
setOf(
|
||||
"and",
|
||||
"the",
|
||||
"for",
|
||||
"with",
|
||||
"about",
|
||||
"into",
|
||||
"from",
|
||||
)
|
||||
private val moodLexicon =
|
||||
mapOf(
|
||||
"tense" to listOf("thriller", "suspense", "tension", "rescue", "crime"),
|
||||
"slow-burn" to listOf("slow", "meditative", "grounded"),
|
||||
"feel-good" to listOf("comedy", "family", "summer", "kind", "warm"),
|
||||
"dark" to listOf("dark", "noir", "horror", "murder", "crime"),
|
||||
"romantic" to listOf("romance", "love", "relationship"),
|
||||
"focused" to listOf("science", "mission", "detective", "investigation", "sci-fi"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.GetUserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesCommand
|
||||
import com.project.movienight.application.ports.input.UpsertUserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
class UserPreferencesService(
|
||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||
) : UpsertUserPreferencesUseCase,
|
||||
GetUserPreferencesUseCase {
|
||||
override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences =
|
||||
userPreferencesRepository.save(
|
||||
UserPreferences(
|
||||
userId = command.userId,
|
||||
weightedGenres = command.weightedGenres,
|
||||
plotTypes = command.plotTypes,
|
||||
eras = command.eras,
|
||||
castAndDirectors = command.castAndDirectors,
|
||||
moods = command.moods,
|
||||
contentTypes = command.contentTypes,
|
||||
),
|
||||
)
|
||||
|
||||
override fun get(userId: java.util.UUID): UserPreferences? = userPreferencesRepository.findByUserId(userId)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.GetUserRecommendationWeightsUseCase
|
||||
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsCommand
|
||||
import com.project.movienight.application.ports.input.UpdateUserRecommendationWeightsUseCase
|
||||
import com.project.movienight.application.ports.output.UserRecommendationWeightsRepositoryPort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class UserRecommendationWeightsService(
|
||||
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
) : GetUserRecommendationWeightsUseCase,
|
||||
UpdateUserRecommendationWeightsUseCase {
|
||||
override fun get(userId: UUID): UserRecommendationWeights {
|
||||
ensureUserExists(userId)
|
||||
return (
|
||||
userRecommendationWeightsRepository.findByUserId(userId)
|
||||
?: UserRecommendationWeights.defaultFor(userId)
|
||||
).normalized()
|
||||
}
|
||||
|
||||
override fun update(command: UpdateUserRecommendationWeightsCommand): UserRecommendationWeights {
|
||||
ensureUserExists(command.userId)
|
||||
return userRecommendationWeightsRepository.save(
|
||||
UserRecommendationWeights(
|
||||
userId = command.userId,
|
||||
relevanceWeight = command.relevanceWeight,
|
||||
qualityWeight = command.qualityWeight,
|
||||
contextWeight = command.contextWeight,
|
||||
noveltyWeight = command.noveltyWeight,
|
||||
diversityWeight = command.diversityWeight,
|
||||
genreVectorWeight = command.genreVectorWeight,
|
||||
plotVectorWeight = command.plotVectorWeight,
|
||||
moodVectorWeight = command.moodVectorWeight,
|
||||
eraVectorWeight = command.eraVectorWeight,
|
||||
peopleVectorWeight = command.peopleVectorWeight,
|
||||
contentTypeVectorWeight = command.contentTypeVectorWeight,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureUserExists(userId: UUID) {
|
||||
userRepository.findById(userId)
|
||||
?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import com.project.movienight.application.ports.input.CreateUserUseCase
|
||||
import com.project.movienight.application.ports.input.DeleteUserUseCase
|
||||
import com.project.movienight.application.ports.input.EditUserCommand
|
||||
import com.project.movienight.application.ports.input.EditUserUseCase
|
||||
import com.project.movienight.application.ports.input.GetAllUsersUseCase
|
||||
import com.project.movienight.application.ports.input.GetUserByIdUseCase
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.config.UserServiceProperties
|
||||
@@ -21,7 +23,9 @@ class UserService(
|
||||
private val userConfig: UserServiceProperties,
|
||||
) : CreateUserUseCase,
|
||||
EditUserUseCase,
|
||||
DeleteUserUseCase {
|
||||
DeleteUserUseCase,
|
||||
GetUserByIdUseCase,
|
||||
GetAllUsersUseCase {
|
||||
override fun create(command: CreateUserCommand): User {
|
||||
if (userConfig.isBlocked(command.name)) {
|
||||
throw BlockedValueException(target = "User", field = "name")
|
||||
@@ -33,6 +37,7 @@ class UserService(
|
||||
name = command.name,
|
||||
email = command.email,
|
||||
library = null,
|
||||
jellyfinUserId = null,
|
||||
)
|
||||
return userRepository.save(user)
|
||||
}
|
||||
@@ -47,14 +52,22 @@ class UserService(
|
||||
|
||||
var user = userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||
|
||||
user = user.copy(name = command.name)
|
||||
user =
|
||||
user.copy(
|
||||
name = command.name,
|
||||
jellyfinUserId = command.jellyfinUserId ?: user.jellyfinUserId,
|
||||
)
|
||||
|
||||
return userRepository.save(user)
|
||||
}
|
||||
|
||||
override fun delete(id: UUID) {
|
||||
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||
|
||||
userRepository.deleteById(id)
|
||||
}
|
||||
|
||||
override fun getById(id: UUID): User =
|
||||
userRepository.findById(id) ?: throw EntityNotFoundException(entity = "User", id = id.toString())
|
||||
|
||||
override fun getAll(): List<User> = userRepository.findAll()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.project.movienight.config
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
|
||||
@ConfigurationProperties(prefix = "integrations.jellyfin")
|
||||
data class JellyfinIntegrationProperties(
|
||||
val enabled: Boolean = false,
|
||||
val baseUrl: String = "",
|
||||
val webUrl: String = "",
|
||||
val apiKey: String = "",
|
||||
val syncIntervalMs: Long = 1_800_000,
|
||||
val requestTimeoutMs: Long = 20_000,
|
||||
val pluginToken: String = "",
|
||||
)
|
||||
@@ -6,4 +6,21 @@ data class Film(
|
||||
val id: UUID,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val contentType: ContentType = ContentType.FILM,
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
val imdbRating: Double? = null,
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
enum class ContentType {
|
||||
FILM,
|
||||
SERIES,
|
||||
EPISODE,
|
||||
OTHER,
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class FilmLibrary(
|
||||
@@ -8,4 +9,5 @@ data class FilmLibrary(
|
||||
val filmId: UUID,
|
||||
val comment: String?,
|
||||
val isViewed: Boolean,
|
||||
val watchedAt: LocalDateTime? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class FilmRating(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val score: Int,
|
||||
val note: String? = null,
|
||||
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
val updatedAt: LocalDateTime = createdAt,
|
||||
)
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class JellyfinSyncState(
|
||||
val userId: UUID,
|
||||
val lastSyncedAt: LocalDateTime? = null,
|
||||
val lastSuccessfulSyncAt: LocalDateTime? = null,
|
||||
val lastError: String? = null,
|
||||
val syncedItemCount: Int = 0,
|
||||
)
|
||||
|
||||
data class JellyfinSyncSummary(
|
||||
val syncedUsers: Int,
|
||||
val skippedUsers: Int,
|
||||
val syncedItems: Int,
|
||||
val durationMs: Long,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class RecommendationContext(
|
||||
val userId: UUID,
|
||||
val contentType: ContentType? = null,
|
||||
val mood: String? = null,
|
||||
val libraryOnly: Boolean = false,
|
||||
val limit: Int = 10,
|
||||
)
|
||||
|
||||
data class RecommendationResult(
|
||||
val film: Film,
|
||||
val score: Double,
|
||||
val reasons: List<String>,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class RecommendationEvent(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val eventType: RecommendationEventType,
|
||||
val score: Double? = null,
|
||||
val relevanceScore: Double? = null,
|
||||
val qualityScore: Double? = null,
|
||||
val contextScore: Double? = null,
|
||||
val noveltyScore: Double? = null,
|
||||
val diversityScore: Double? = null,
|
||||
val createdAt: LocalDateTime = LocalDateTime.now(),
|
||||
)
|
||||
|
||||
enum class RecommendationEventType {
|
||||
RECOMMENDED,
|
||||
ACCEPTED,
|
||||
REJECTED,
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
enum class RecommendationStyle {
|
||||
BALANCED,
|
||||
QUALITY_FIRST,
|
||||
MOOD_FIRST,
|
||||
DISCOVERY,
|
||||
SIMILAR_TO_FAVORITES,
|
||||
}
|
||||
@@ -7,4 +7,6 @@ data class User(
|
||||
val name: String,
|
||||
val email: String,
|
||||
val library: FilmLibrary?,
|
||||
val preferences: UserPreferences? = null,
|
||||
val jellyfinUserId: String? = null,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class UserPreferences(
|
||||
val userId: UUID,
|
||||
val weightedGenres: Map<String, Int> = emptyMap(),
|
||||
val plotTypes: List<String> = emptyList(),
|
||||
val eras: List<String> = emptyList(),
|
||||
val castAndDirectors: List<String> = emptyList(),
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<ContentType> = emptyList(),
|
||||
)
|
||||
@@ -0,0 +1,233 @@
|
||||
package com.project.movienight.domain.model
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class UserRecommendationWeights(
|
||||
val userId: UUID,
|
||||
val relevanceWeight: Double = DEFAULT_RELEVANCE_WEIGHT,
|
||||
val qualityWeight: Double = DEFAULT_QUALITY_WEIGHT,
|
||||
val contextWeight: Double = DEFAULT_CONTEXT_WEIGHT,
|
||||
val noveltyWeight: Double = DEFAULT_NOVELTY_WEIGHT,
|
||||
val diversityWeight: Double = DEFAULT_DIVERSITY_WEIGHT,
|
||||
val genreVectorWeight: Double = DEFAULT_GENRE_VECTOR_WEIGHT,
|
||||
val plotVectorWeight: Double = DEFAULT_PLOT_VECTOR_WEIGHT,
|
||||
val moodVectorWeight: Double = DEFAULT_MOOD_VECTOR_WEIGHT,
|
||||
val eraVectorWeight: Double = DEFAULT_ERA_VECTOR_WEIGHT,
|
||||
val peopleVectorWeight: Double = DEFAULT_PEOPLE_VECTOR_WEIGHT,
|
||||
val contentTypeVectorWeight: Double = DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
|
||||
val updatedAt: LocalDateTime = LocalDateTime.now(),
|
||||
) {
|
||||
fun normalized(updatedAt: LocalDateTime = this.updatedAt): UserRecommendationWeights {
|
||||
val scoreWeights =
|
||||
normalizeBounded(
|
||||
values =
|
||||
listOf(
|
||||
relevanceWeight,
|
||||
qualityWeight,
|
||||
contextWeight,
|
||||
noveltyWeight,
|
||||
diversityWeight,
|
||||
),
|
||||
defaults = DEFAULT_SCORE_WEIGHTS,
|
||||
min = MIN_SCORE_WEIGHT,
|
||||
max = MAX_SCORE_WEIGHT,
|
||||
)
|
||||
val vectorWeights =
|
||||
normalizeBounded(
|
||||
values =
|
||||
listOf(
|
||||
genreVectorWeight,
|
||||
plotVectorWeight,
|
||||
moodVectorWeight,
|
||||
eraVectorWeight,
|
||||
peopleVectorWeight,
|
||||
contentTypeVectorWeight,
|
||||
),
|
||||
defaults = DEFAULT_VECTOR_WEIGHTS,
|
||||
min = MIN_VECTOR_WEIGHT,
|
||||
max = MAX_VECTOR_WEIGHT,
|
||||
)
|
||||
|
||||
return copy(
|
||||
relevanceWeight = scoreWeights[0],
|
||||
qualityWeight = scoreWeights[1],
|
||||
contextWeight = scoreWeights[2],
|
||||
noveltyWeight = scoreWeights[3],
|
||||
diversityWeight = scoreWeights[4],
|
||||
genreVectorWeight = vectorWeights[0],
|
||||
plotVectorWeight = vectorWeights[1],
|
||||
moodVectorWeight = vectorWeights[2],
|
||||
eraVectorWeight = vectorWeights[3],
|
||||
peopleVectorWeight = vectorWeights[4],
|
||||
contentTypeVectorWeight = vectorWeights[5],
|
||||
updatedAt = updatedAt,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_RELEVANCE_WEIGHT = 0.55
|
||||
const val DEFAULT_QUALITY_WEIGHT = 0.15
|
||||
const val DEFAULT_CONTEXT_WEIGHT = 0.10
|
||||
const val DEFAULT_NOVELTY_WEIGHT = 0.10
|
||||
const val DEFAULT_DIVERSITY_WEIGHT = 0.10
|
||||
|
||||
const val DEFAULT_GENRE_VECTOR_WEIGHT = 0.25
|
||||
const val DEFAULT_PLOT_VECTOR_WEIGHT = 0.35
|
||||
const val DEFAULT_MOOD_VECTOR_WEIGHT = 0.15
|
||||
const val DEFAULT_ERA_VECTOR_WEIGHT = 0.10
|
||||
const val DEFAULT_PEOPLE_VECTOR_WEIGHT = 0.10
|
||||
const val DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT = 0.05
|
||||
|
||||
const val MIN_SCORE_WEIGHT = 0.05
|
||||
const val MAX_SCORE_WEIGHT = 0.75
|
||||
const val MIN_VECTOR_WEIGHT = 0.03
|
||||
const val MAX_VECTOR_WEIGHT = 0.60
|
||||
|
||||
private val DEFAULT_SCORE_WEIGHTS =
|
||||
listOf(
|
||||
DEFAULT_RELEVANCE_WEIGHT,
|
||||
DEFAULT_QUALITY_WEIGHT,
|
||||
DEFAULT_CONTEXT_WEIGHT,
|
||||
DEFAULT_NOVELTY_WEIGHT,
|
||||
DEFAULT_DIVERSITY_WEIGHT,
|
||||
)
|
||||
private val DEFAULT_VECTOR_WEIGHTS =
|
||||
listOf(
|
||||
DEFAULT_GENRE_VECTOR_WEIGHT,
|
||||
DEFAULT_PLOT_VECTOR_WEIGHT,
|
||||
DEFAULT_MOOD_VECTOR_WEIGHT,
|
||||
DEFAULT_ERA_VECTOR_WEIGHT,
|
||||
DEFAULT_PEOPLE_VECTOR_WEIGHT,
|
||||
DEFAULT_CONTENT_TYPE_VECTOR_WEIGHT,
|
||||
)
|
||||
|
||||
fun defaultFor(userId: UUID): UserRecommendationWeights = UserRecommendationWeights(userId = userId)
|
||||
|
||||
fun forStyle(
|
||||
userId: UUID,
|
||||
style: RecommendationStyle,
|
||||
): UserRecommendationWeights =
|
||||
when (style) {
|
||||
RecommendationStyle.BALANCED -> {
|
||||
defaultFor(userId)
|
||||
}
|
||||
|
||||
RecommendationStyle.QUALITY_FIRST -> {
|
||||
UserRecommendationWeights(
|
||||
userId = userId,
|
||||
relevanceWeight = 0.40,
|
||||
qualityWeight = 0.35,
|
||||
contextWeight = 0.10,
|
||||
noveltyWeight = 0.05,
|
||||
diversityWeight = 0.10,
|
||||
)
|
||||
}
|
||||
|
||||
RecommendationStyle.MOOD_FIRST -> {
|
||||
UserRecommendationWeights(
|
||||
userId = userId,
|
||||
relevanceWeight = 0.45,
|
||||
qualityWeight = 0.10,
|
||||
contextWeight = 0.25,
|
||||
noveltyWeight = 0.10,
|
||||
diversityWeight = 0.10,
|
||||
moodVectorWeight = 0.30,
|
||||
)
|
||||
}
|
||||
|
||||
RecommendationStyle.DISCOVERY -> {
|
||||
UserRecommendationWeights(
|
||||
userId = userId,
|
||||
relevanceWeight = 0.30,
|
||||
qualityWeight = 0.10,
|
||||
contextWeight = 0.10,
|
||||
noveltyWeight = 0.25,
|
||||
diversityWeight = 0.25,
|
||||
)
|
||||
}
|
||||
|
||||
RecommendationStyle.SIMILAR_TO_FAVORITES -> {
|
||||
UserRecommendationWeights(
|
||||
userId = userId,
|
||||
relevanceWeight = 0.70,
|
||||
qualityWeight = 0.10,
|
||||
contextWeight = 0.10,
|
||||
noveltyWeight = 0.05,
|
||||
diversityWeight = 0.05,
|
||||
genreVectorWeight = 0.30,
|
||||
plotVectorWeight = 0.40,
|
||||
peopleVectorWeight = 0.15,
|
||||
)
|
||||
}
|
||||
}.normalized()
|
||||
|
||||
private fun normalizeBounded(
|
||||
values: List<Double>,
|
||||
defaults: List<Double>,
|
||||
min: Double,
|
||||
max: Double,
|
||||
): List<Double> {
|
||||
val sanitized = values.map { value -> if (value.isFinite() && value > 0.0) value else 0.0 }
|
||||
val source = sanitized.takeIf { it.sum() > 0.0 } ?: defaults
|
||||
val normalized = source.map { it / source.sum() }
|
||||
return projectToBounds(normalized, min, max)
|
||||
}
|
||||
|
||||
private fun projectToBounds(
|
||||
values: List<Double>,
|
||||
min: Double,
|
||||
max: Double,
|
||||
): List<Double> {
|
||||
val result = values.map { it.coerceIn(min, max) }.toMutableList()
|
||||
var iterations = 0
|
||||
var adjusting = true
|
||||
|
||||
while (iterations < values.size * 2 && adjusting) {
|
||||
iterations += 1
|
||||
val diff = 1.0 - result.sum()
|
||||
if (kotlin.math.abs(diff) <= NORMALIZATION_EPSILON) {
|
||||
adjusting = false
|
||||
} else {
|
||||
adjusting = redistribute(result, diff, min, max)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
private fun redistribute(
|
||||
result: MutableList<Double>,
|
||||
diff: Double,
|
||||
min: Double,
|
||||
max: Double,
|
||||
): Boolean =
|
||||
if (diff > 0.0) {
|
||||
val candidates = result.indices.filter { result[it] < max }
|
||||
val capacity = candidates.sumOf { max - result[it] }
|
||||
if (capacity > 0.0) {
|
||||
candidates.forEach { index ->
|
||||
val increment = diff * ((max - result[index]) / capacity)
|
||||
result[index] = (result[index] + increment).coerceAtMost(max)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
val candidates = result.indices.filter { result[it] > min }
|
||||
val capacity = candidates.sumOf { result[it] - min }
|
||||
if (capacity > 0.0) {
|
||||
candidates.forEach { index ->
|
||||
val decrement = -diff * ((result[index] - min) / capacity)
|
||||
result[index] = (result[index] - decrement).coerceAtLeast(min)
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private const val NORMALIZATION_EPSILON = 0.0000001
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user