Refactor Plugin UI for Jellyfin 10.11+ #52
@@ -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 = `
|
||||
|
Buttons are created with Buttons are created with `btn.is = 'emby-button'`, but `is` is an HTML attribute (for customized built-in elements) and is not reliably reflected as a JS property. This may result in the element not being upgraded/styled as an `emby-button`. Prefer setting the attribute (`btn.setAttribute('is','emby-button')`) or creating the element with the `is` option, and apply the same fix to the rating-grid buttons.
|
||||
<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) => {
|
||||
|
The rating button is icon-only; relying on The rating button is icon-only; relying on `title` alone is not sufficient for accessible naming in many screen readers. Add an explicit `aria-label` (and/or visually-hidden text) so the control is discoverable to assistive tech.
|
||||
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
createIconButtontreatsicon(e.g.star_rate) as an extra CSS class on the<span class="material-icons ...">, but Material Icons expects the glyph name as the element text content. As written, the star icon is likely to render blank. Set the span’s textContent/innerText to the icon name (and keepmaterial-iconsas the class), or use the same DOM structure Jellyfin uses for detail buttons.The guard
!document.querySelector('.btnMovieNightRecommend')is global, but this script now renders Recommend/Add buttons in multiple places (library toolbar and home section). If the home section is present, this condition will prevent the library toolbar buttons from being injected (and vice versa). Scope the checks to the intended container (e.g.,!toolBar.querySelector(...)) and consider checking each button independently so one missing button doesn’t block the other.showRatingDialoghard-codes dialog colors (#222background,whitetext) and several layout styles inline. This will likely clash with non-default themes and undermines the goal of using native Jellyfin styling. Prefer using Jellyfin dialog/backdrop classes (or CSS variables like--theme-*) and minimal inline styles so the dialog inherits theme styling.createTextButtonusesinnerHTMLto insert the button label even though the content is plain text. UsingtextContent(and creating the<span>node) avoids unnecessary HTML parsing and reduces XSS risk if this helper is ever reused with non-constant text.