refactor: обновить use case слои и интеграцию Jellyfin #55
@@ -33,6 +33,7 @@ dependencies {
|
||||
|
||||
implementation(libs.spring.boot.starter.web)
|
||||
implementation(libs.spring.boot.starter.actuator)
|
||||
implementation(libs.spring.boot.starter.aop)
|
||||
implementation(libs.spring.boot.starter.security)
|
||||
implementation(libs.spring.boot.starter.cache)
|
||||
implementation(libs.spring.boot.starter.data.jdbc)
|
||||
|
||||
@@ -17,6 +17,7 @@ mockk = "1.13.13"
|
||||
spring-boot-starter-oauth2-client = { module = "org.springframework.boot:spring-boot-starter-oauth2-client" }
|
||||
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web" }
|
||||
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator" }
|
||||
spring-boot-starter-aop = { module = "org.springframework.boot:spring-boot-starter-aop" }
|
||||
spring-boot-starter-security = { module = "org.springframework.boot:spring-boot-starter-security" }
|
||||
spring-boot-starter-cache = { module = "org.springframework.boot:spring-boot-starter-cache" }
|
||||
spring-boot-starter-data-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc" }
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
@@ -0,0 +1,50 @@
|
||||
using System.Collections.Generic;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// MovieNight plugin settings persisted by Jellyfin.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether integration calls are enabled.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the MovieNight backend base URL.
|
||||
/// </summary>
|
||||
public string BackendBaseUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the backend plugin token.
|
||||
/// </summary>
|
||||
public string ApiToken { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the periodic sync interval in minutes.
|
||||
/// </summary>
|
||||
public int SyncIntervalMinutes { get; set; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether playback stop events are pushed to MovieNight.
|
||||
/// </summary>
|
||||
public bool EnablePlaybackEvents { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether periodic backend sync is enabled.
|
||||
/// </summary>
|
||||
public bool EnablePeriodicSync { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets enabled Jellyfin library ids. Empty means all libraries.
|
||||
/// </summary>
|
||||
public List<string> EnabledLibraryIds { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the path where .strm files will be created.
|
||||
/// </summary>
|
||||
public string StrmOutputPath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
const movieNightConfigPage = {
|
||||
pluginId: "42c72919-d6ff-4f62-bb8c-0fac39efafdb",
|
||||
|
||||
loadConfiguration(view) {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.getPluginConfiguration(this.pluginId)
|
||||
.then((config) => {
|
||||
view.querySelector("#BackendBaseUrl").value =
|
||||
config.BackendBaseUrl || "";
|
||||
view.querySelector("#ApiToken").value = config.ApiToken || "";
|
||||
view.querySelector("#SyncIntervalMinutes").value =
|
||||
config.SyncIntervalMinutes || 30;
|
||||
view.querySelector("#StrmOutputPath").value =
|
||||
config.StrmOutputPath || "";
|
||||
view.querySelector("#Enabled").checked = config.Enabled || false;
|
||||
view.querySelector("#EnablePeriodicSync").checked =
|
||||
config.EnablePeriodicSync !== false;
|
||||
view.querySelector("#EnablePlaybackEvents").checked =
|
||||
config.EnablePlaybackEvents !== false;
|
||||
|
||||
const uiScriptUrl = ApiClient.getUrl("web/ConfigurationPage", {
|
||||
name: "MovieNight.ui.js",
|
||||
});
|
||||
view.querySelector("#UIScriptUrl").innerText = uiScriptUrl;
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
|
||||
saveConfiguration(view) {
|
||||
const form = view.querySelector("#MovieNightConfigForm");
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.getPluginConfiguration(this.pluginId)
|
||||
.then((config) => {
|
||||
config.BackendBaseUrl = form.querySelector("#BackendBaseUrl").value;
|
||||
config.ApiToken = form.querySelector("#ApiToken").value;
|
||||
config.SyncIntervalMinutes = parseInt(
|
||||
form.querySelector("#SyncIntervalMinutes").value || "30",
|
||||
10,
|
||||
);
|
||||
config.StrmOutputPath = form.querySelector("#StrmOutputPath").value;
|
||||
config.Enabled = form.querySelector("#Enabled").checked;
|
||||
config.EnablePeriodicSync =
|
||||
form.querySelector("#EnablePeriodicSync").checked;
|
||||
config.EnablePlaybackEvents =
|
||||
form.querySelector("#EnablePlaybackEvents").checked;
|
||||
|
||||
return ApiClient.updatePluginConfiguration(this.pluginId, config);
|
||||
})
|
||||
.then((result) => {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
|
||||
testConnection() {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
return ApiClient.ajax({
|
||||
type: "POST",
|
||||
url: ApiClient.getUrl("MovieNight/TestConnection"),
|
||||
})
|
||||
.then((result) => {
|
||||
Dashboard.alert((result && result.message) || "OK");
|
||||
})
|
||||
.catch(() => {
|
||||
Dashboard.alert("MovieNight connection test failed");
|
||||
})
|
||||
.finally(() => {
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export default function (view) {
|
||||
movieNightConfigPage.loadConfiguration(view);
|
||||
|
||||
view
|
||||
.querySelector("#MovieNightConfigForm")
|
||||
.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
movieNightConfigPage.saveConfiguration(view);
|
||||
});
|
||||
|
||||
view.querySelector("#TestConnection").addEventListener("click", (event) => {
|
||||
event.preventDefault();
|
||||
movieNightConfigPage.testConnection();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MovieNight</title>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
id="MovieNightConfigPage"
|
||||
data-role="page"
|
||||
class="page type-interior pluginConfigurationPage"
|
||||
data-controller="__plugin/MovieNight.js">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="MovieNightConfigForm">
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="BackendBaseUrl">Backend URL</label>
|
||||
<input is="emby-input" id="BackendBaseUrl" name="BackendBaseUrl" type="url" placeholder="http://localhost:8080" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="ApiToken">Plugin token</label>
|
||||
<input is="emby-input" id="ApiToken" name="ApiToken" type="password" autocomplete="new-password" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="SyncIntervalMinutes">Sync interval minutes</label>
|
||||
<input is="emby-input" id="SyncIntervalMinutes" name="SyncIntervalMinutes" type="number" min="1" max="1440" />
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="StrmOutputPath">STRM output path</label>
|
||||
<input is="emby-input" id="StrmOutputPath" name="StrmOutputPath" type="text" placeholder="/data/movies/movienight" />
|
||||
<div class="fieldDescription">Directory where .strm files will be created for new films.</div>
|
||||
</div>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="Enabled" name="Enabled" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable MovieNight integration</span>
|
||||
</label>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="EnablePeriodicSync" name="EnablePeriodicSync" type="checkbox" is="emby-checkbox" />
|
||||
<span>Enable periodic backend sync</span>
|
||||
</label>
|
||||
|
||||
<label class="checkboxContainer">
|
||||
<input id="EnablePlaybackEvents" name="EnablePlaybackEvents" type="checkbox" is="emby-checkbox" />
|
||||
<span>Send playback stop events</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="button" id="TestConnection" class="raised block">
|
||||
<span>Test connection</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 2em; padding: 1em; background: #333; border-radius: 4px;">
|
||||
<h3>UI Integration</h3>
|
||||
<p>To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin <strong>Custom JavaScript</strong> setting (Dashboard > General):</p>
|
||||
<code id="UIScriptUrl" style="display: block; padding: 0.5em; background: #000; word-break: break-all;"></code>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,339 @@
|
||||
(function () {
|
||||
const PLUGIN_ID = "42c72919-d6ff-4f62-bb8c-0fac39efafdb";
|
||||
|
||||
function getAlert() {
|
||||
if (typeof Dashboard !== 'undefined' && Dashboard.alert) {
|
||||
return (options) => Dashboard.alert(options);
|
||||
}
|
||||
return (options) => {
|
||||
const msg = typeof options === 'string' ? options : (options.text || options.title);
|
||||
alert(msg);
|
||||
};
|
||||
}
|
||||
|
||||
const showMsg = getAlert();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function createIconButton(icon, title, className, onClick) {
|
||||
const btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.is = 'emby-button';
|
||||
btn.className = `button-flat detailButton emby-button ${className}`;
|
||||
btn.title = title;
|
||||
btn.innerHTML = `
|
||||
<div class="detailButton-content">
|
||||
<span class="material-icons detailButton-icon ${icon}" aria-hidden="true"></span>
|
||||
</div>
|
||||
`;
|
||||
btn.onclick = onClick;
|
||||
return btn;
|
||||
}
|
||||
|
||||
function injectUI() {
|
||||
// 1. Item Detail Page
|
||||
const detailButtons = document.querySelector('.mainDetailButtons');
|
||||
if (detailButtons) {
|
||||
const itemId = getItemIdFromUrl();
|
||||
if (itemId) {
|
||||
// MovieNight Rating
|
||||
if (!document.querySelector('.btnMovieNightRate')) {
|
||||
const rateBtn = createIconButton('star_rate', 'Rate on MovieNight', 'btnMovieNightRate', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); showRatingDialog(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, rateBtn);
|
||||
}
|
||||
// Mark Viewed in MovieNight
|
||||
if (!document.querySelector('.btnMovieNightMarkViewed')) {
|
||||
const viewedBtn = createIconButton('visibility', 'Mark Viewed in MovieNight', 'btnMovieNightMarkViewed', (e) => {
|
||||
e.preventDefault(); e.stopPropagation(); submitViewed(itemId);
|
||||
});
|
||||
insertInDetailRow(detailButtons, viewedBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}));
|
||||
}
|
||||
|
||||
// 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));
|
||||
|
||||
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 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 showRecommendation() {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
const response = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/Users/${userId}/Recommendations`));
|
||||
const recommendations = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
|
||||
if (recommendations && recommendations.length > 0) {
|
||||
const rec = recommendations[0];
|
||||
const film = rec.film || rec;
|
||||
showMsg({
|
||||
title: 'MovieNight Recommendation',
|
||||
text: `How about watching: ${film.title}?\n\nReason: ${rec.reasons?.join(', ') || 'Based on your preferences'}`
|
||||
});
|
||||
} else {
|
||||
showMsg('No recommendations found at the moment.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to get recommendations', err);
|
||||
showMsg('Failed to get recommendations. Check your API token and MovieNight status.');
|
||||
}
|
||||
}
|
||||
|
||||
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(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. 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) {
|
||||
const userId = ApiClient.getCurrentUserId();
|
||||
try {
|
||||
await ApiClient.ajax({
|
||||
type: 'POST',
|
||||
url: ApiClient.getUrl(`MovieNight/Users/${userId}/Ratings/Films/${itemId}`),
|
||||
data: JSON.stringify({ score: parseInt(score), note: 'From Jellyfin UI' }),
|
||||
contentType: 'application/json'
|
||||
});
|
||||
showMsg('Rating submitted to MovieNight!');
|
||||
} catch (err) {
|
||||
showMsg('Failed to submit rating.');
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
})();
|
||||
@@ -0,0 +1,229 @@
|
||||
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;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Admin endpoints for the MovieNight plugin.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("MovieNight")]
|
||||
public class MovieNightController : ControllerBase
|
||||
{
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly MovieNightSyncService _syncService;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightController"/> class.
|
||||
/// </summary>
|
||||
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;
|
||||
return new MovieNightPluginStatus(
|
||||
Enabled: configuration?.Enabled ?? false,
|
||||
BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty,
|
||||
PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false,
|
||||
PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false,
|
||||
SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tests backend connectivity.
|
||||
/// </summary>
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Triggers backend sync.
|
||||
/// </summary>
|
||||
/// <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);
|
||||
return Ok("Sync triggered");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets backend sync state.
|
||||
/// </summary>
|
||||
/// <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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recommendations for the current user.
|
||||
/// </summary>
|
||||
[HttpGet("Users/{userId}/Recommendations")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<string>> GetRecommendations(
|
||||
[FromRoute] string userId,
|
||||
[FromQuery] string? contentType,
|
||||
[FromQuery] string? mood,
|
||||
[FromQuery] int limit = 10,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
[FromBody] RatingRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _backendClient.PostRatingAsync(userId, filmId, request.Score, request.Note, cancellationToken).ConfigureAwait(false);
|
||||
return Ok();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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,
|
||||
[FromBody] ViewedRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, 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;
|
||||
if (config == null || string.IsNullOrWhiteSpace(config.StrmOutputPath))
|
||||
{
|
||||
return BadRequest("STRM output path is not configured.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Title))
|
||||
{
|
||||
return BadRequest("Movie title is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Construct name: "Movie Name (Year) [imdbid-ttXXXXXXX]"
|
||||
var folderName = request.Title.Trim();
|
||||
if (request.Year.HasValue)
|
||||
{
|
||||
folderName += $" ({request.Year})";
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.ImdbId))
|
||||
{
|
||||
var ttId = request.ImdbId.Trim().ToLowerInvariant();
|
||||
if (!ttId.StartsWith("tt")) ttId = "tt" + ttId;
|
||||
folderName += $" [imdbid-{ttId}]";
|
||||
}
|
||||
|
||||
// Sanitize for file system
|
||||
var invalidChars = Path.GetInvalidFileNameChars();
|
||||
var safeFolderName = new string(folderName.Select(c => invalidChars.Contains(c) ? '_' : c).ToArray());
|
||||
|
||||
var movieDirectory = Path.Combine(config.StrmOutputPath, safeFolderName);
|
||||
if (!Directory.Exists(movieDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(movieDirectory);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return StatusCode(500, $"Failed to create film: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Create film request.
|
||||
/// </summary>
|
||||
public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId);
|
||||
|
||||
/// <summary>
|
||||
/// Rating request.
|
||||
/// </summary>
|
||||
public sealed record RatingRequest(int Score, string? Note);
|
||||
|
||||
/// <summary>
|
||||
/// Viewed request.
|
||||
/// </summary>
|
||||
public sealed record ViewedRequest(DateTimeOffset? WatchedAt);
|
||||
|
||||
/// <summary>
|
||||
/// MovieNight plugin status response.
|
||||
/// </summary>
|
||||
/// <param name="Enabled">Whether integration is enabled.</param>
|
||||
/// <param name="BackendBaseUrl">Backend base URL.</param>
|
||||
/// <param name="PeriodicSyncEnabled">Whether periodic sync is enabled.</param>
|
||||
/// <param name="PlaybackEventsEnabled">Whether playback events are enabled.</param>
|
||||
/// <param name="SyncIntervalMinutes">Sync interval in minutes.</param>
|
||||
public sealed record MovieNightPluginStatus(
|
||||
bool Enabled,
|
||||
string BackendBaseUrl,
|
||||
bool PeriodicSyncEnabled,
|
||||
bool PlaybackEventsEnabled,
|
||||
int SyncIntervalMinutes);
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net9.0</TargetFramework>
|
||||
<RootNamespace>Jellyfin.Plugin.MovieNight</RootNamespace>
|
||||
<AssemblyName>Jellyfin.Plugin.MovieNight</AssemblyName>
|
||||
<Version>1.0.0.1</Version>
|
||||
<PackageLicenseExpression>GPL-3.0-or-later</PackageLicenseExpression>
|
||||
<Nullable>enable</Nullable>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
<PackageReference Include="Jellyfin.Common" Version="10.11.5">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Controller" Version="10.11.5">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
|
||||
<ExcludeAssets>runtime</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Remove="Configuration\configPage.html" />
|
||||
<None Remove="Configuration\config.js" />
|
||||
<None Remove="Configuration\ui.js" />
|
||||
<EmbeddedResource Include="Configuration\configPage.html" />
|
||||
<EmbeddedResource Include="Configuration\config.js" />
|
||||
<EmbeddedResource Include="Configuration\ui.js" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.MovieNight.Configuration;
|
||||
using MediaBrowser.Common.Configuration;
|
||||
using MediaBrowser.Common.Plugins;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
using MediaBrowser.Model.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight;
|
||||
|
||||
/// <summary>
|
||||
/// MovieNight Jellyfin plugin.
|
||||
/// </summary>
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
/// </summary>
|
||||
/// <param name="applicationPaths">Application paths.</param>
|
||||
/// <param name="xmlSerializer">XML serializer.</param>
|
||||
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
|
||||
: base(applicationPaths, xmlSerializer)
|
||||
{
|
||||
Instance = this;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Name => "MovieNight";
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb");
|
||||
|
||||
/// <inheritdoc />
|
||||
public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend.";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current plugin instance.
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
return
|
||||
[
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name,
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.configPage.html",
|
||||
GetType().Namespace)
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name + ".js",
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.config.js",
|
||||
GetType().Namespace)
|
||||
},
|
||||
new PluginPageInfo
|
||||
{
|
||||
Name = Name + ".ui.js",
|
||||
EmbeddedResourcePath = string.Format(
|
||||
CultureInfo.InvariantCulture,
|
||||
"{0}.Configuration.ui.js",
|
||||
GetType().Namespace)
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Jellyfin.Plugin.MovieNight.Services;
|
||||
using MediaBrowser.Controller;
|
||||
using MediaBrowser.Controller.Plugins;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight;
|
||||
|
||||
/// <summary>
|
||||
/// Registers MovieNight services with Jellyfin.
|
||||
/// </summary>
|
||||
public class PluginServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
|
||||
{
|
||||
serviceCollection.AddSingleton<MovieNightBackendClient>();
|
||||
serviceCollection.AddSingleton<MovieNightSyncService>();
|
||||
serviceCollection.AddHostedService<MovieNightPeriodicSyncService>();
|
||||
serviceCollection.AddHostedService<MovieNightPlaybackEventService>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,281 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin HTTP client for the MovieNight backend.
|
||||
/// </summary>
|
||||
public class MovieNightBackendClient
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly ILogger<MovieNightBackendClient> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightBackendClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public MovieNightBackendClient(ILogger<MovieNightBackendClient> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(20)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls backend health.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Connection result.</returns>
|
||||
public async Task<MovieNightConnectionResult> TestConnectionAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var payload = new MovieNightEventPayload(
|
||||
EventId: $"plugin-test:{Guid.NewGuid():N}",
|
||||
EventType: "playback.stopped",
|
||||
OccurredAt: DateTimeOffset.UtcNow,
|
||||
JellyfinUserId: "movienight-plugin-test-user",
|
||||
ItemId: "movienight-plugin-test-item",
|
||||
PayloadVersion: 1,
|
||||
Payload: new Dictionary<string, object?>
|
||||
{
|
||||
["source"] = "config-test"
|
||||
});
|
||||
var request = CreateEventRequest(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return MovieNightConnectionResult.Failed("Plugin is not configured.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
return response.IsSuccessStatusCode
|
||||
? MovieNightConnectionResult.Ok()
|
||||
: MovieNightConnectionResult.Failed($"Backend event endpoint returned {(int)response.StatusCode}.");
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogWarning(ex, "MovieNight connection test failed");
|
||||
return MovieNightConnectionResult.Failed(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes library sync data to the backend.
|
||||
/// </summary>
|
||||
/// <param name="payload">Sync payload.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response body.</returns>
|
||||
public async Task<string> SyncAsync(object payload, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/sync");
|
||||
if (request is null)
|
||||
{
|
||||
return "Plugin is not configured.";
|
||||
}
|
||||
|
||||
request.Content = JsonContent.Create(payload, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets recommendations for a user.
|
||||
/// </summary>
|
||||
public async Task<string> GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = $"?limit={limit}";
|
||||
if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}";
|
||||
if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}";
|
||||
|
||||
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/recommendations{query}");
|
||||
if (request is null) return "Plugin is not configured.";
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts a rating for a film.
|
||||
/// </summary>
|
||||
public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/ratings/films/{filmId}");
|
||||
if (request is null) return;
|
||||
|
||||
request.Content = JsonContent.Create(new { score, note }, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets ratings for a user.
|
||||
/// </summary>
|
||||
public async Task<string> GetRatingsAsync(string userId, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/ratings");
|
||||
if (request is null) return "[]";
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marks a film as viewed.
|
||||
/// </summary>
|
||||
public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed");
|
||||
if (request is null) return;
|
||||
|
||||
request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions);
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads backend sync state.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Backend response body.</returns>
|
||||
public async Task<string> GetSyncStateAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Get, "/api/integrations/jellyfin/sync-state");
|
||||
if (request is null)
|
||||
{
|
||||
return "Plugin is not configured.";
|
||||
}
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes an event payload to the backend event endpoint.
|
||||
/// </summary>
|
||||
/// <param name="payload">Event payload.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task.</returns>
|
||||
public async Task PushEventAsync(MovieNightEventPayload payload, CancellationToken cancellationToken)
|
||||
{
|
||||
for (var attempt = 1; attempt <= 3; attempt++)
|
||||
{
|
||||
var request = CreateEventRequest(payload);
|
||||
if (request is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
if (response.IsSuccessStatusCode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if ((int)response.StatusCode == 401)
|
||||
{
|
||||
_logger.LogWarning("MovieNight event push was rejected with 401 Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogDebug("MovieNight event push returned status {StatusCode}", response.StatusCode);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
_logger.LogDebug(ex, "MovieNight event push attempt {Attempt} failed", attempt);
|
||||
}
|
||||
|
||||
if (attempt < 3)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(attempt * 2), cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HttpRequestMessage? CreateEventRequest(MovieNightEventPayload payload)
|
||||
{
|
||||
var request = CreateRequest(HttpMethod.Post, "/api/integrations/jellyfin/events");
|
||||
if (request is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
request.Content = JsonContent.Create(payload, options: JsonOptions);
|
||||
return request;
|
||||
}
|
||||
|
||||
private static string? GetBaseUrl()
|
||||
{
|
||||
var value = Plugin.Instance?.Configuration.BackendBaseUrl?.Trim();
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static bool IsEnabled()
|
||||
{
|
||||
var configuration = Plugin.Instance?.Configuration;
|
||||
return configuration is { Enabled: true } && !string.IsNullOrWhiteSpace(configuration.BackendBaseUrl);
|
||||
}
|
||||
|
||||
private static HttpRequestMessage? CreateRequest(HttpMethod method, string path)
|
||||
{
|
||||
if (!IsEnabled())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var baseUrl = GetBaseUrl();
|
||||
if (baseUrl is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(method, new Uri(baseUrl + path));
|
||||
var token = Plugin.Instance?.Configuration.ApiToken;
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.Add("X-MovieNight-Plugin-Token", token);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Backend connection result.
|
||||
/// </summary>
|
||||
/// <param name="Success">Whether the call succeeded.</param>
|
||||
/// <param name="Message">Result message.</param>
|
||||
public sealed record MovieNightConnectionResult(bool Success, string Message)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
/// <returns>Connection result.</returns>
|
||||
public static MovieNightConnectionResult Ok() => new(true, "OK");
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result.
|
||||
/// </summary>
|
||||
/// <param name="message">Failure message.</param>
|
||||
/// <returns>Connection result.</returns>
|
||||
public static MovieNightConnectionResult Failed(string message) => new(false, message);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Event payload sent to MovieNight.
|
||||
/// </summary>
|
||||
/// <param name="EventId">Idempotency key.</param>
|
||||
/// <param name="EventType">Event type.</param>
|
||||
/// <param name="OccurredAt">Event timestamp.</param>
|
||||
/// <param name="JellyfinUserId">Jellyfin user id.</param>
|
||||
/// <param name="ItemId">Jellyfin item id.</param>
|
||||
/// <param name="PayloadVersion">Payload version.</param>
|
||||
/// <param name="Payload">Extra event data.</param>
|
||||
public sealed record MovieNightEventPayload(
|
||||
[property: JsonPropertyName("event_id")]
|
||||
string EventId,
|
||||
[property: JsonPropertyName("event_type")]
|
||||
string EventType,
|
||||
[property: JsonPropertyName("occurred_at")]
|
||||
DateTimeOffset OccurredAt,
|
||||
[property: JsonPropertyName("jellyfin_user_id")]
|
||||
string JellyfinUserId,
|
||||
[property: JsonPropertyName("item_id")]
|
||||
string ItemId,
|
||||
[property: JsonPropertyName("payload_version")]
|
||||
int PayloadVersion,
|
||||
[property: JsonPropertyName("payload")]
|
||||
IReadOnlyDictionary<string, object?> Payload);
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Periodically asks MovieNight to run its current Jellyfin sync.
|
||||
/// </summary>
|
||||
public sealed class MovieNightPeriodicSyncService : BackgroundService
|
||||
{
|
||||
private readonly MovieNightSyncService _syncService;
|
||||
private readonly ILogger<MovieNightPeriodicSyncService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightPeriodicSyncService"/> class.
|
||||
/// </summary>
|
||||
public MovieNightPeriodicSyncService(
|
||||
MovieNightSyncService syncService,
|
||||
ILogger<MovieNightPeriodicSyncService> logger)
|
||||
{
|
||||
_syncService = syncService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
var delay = GetDelay();
|
||||
try
|
||||
{
|
||||
await Task.Delay(delay, stoppingToken).ConfigureAwait(false);
|
||||
if (!ShouldRun())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
await _syncService.PerformSyncAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "MovieNight periodic sync failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool ShouldRun()
|
||||
{
|
||||
var configuration = Plugin.Instance?.Configuration;
|
||||
return configuration is { Enabled: true, EnablePeriodicSync: true };
|
||||
}
|
||||
|
||||
private static TimeSpan GetDelay()
|
||||
{
|
||||
var minutes = Plugin.Instance?.Configuration.SyncIntervalMinutes ?? 30;
|
||||
return TimeSpan.FromMinutes(Math.Clamp(minutes, 1, 1440));
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Session;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to Jellyfin playback events and forwards thin payloads.
|
||||
/// </summary>
|
||||
public sealed class MovieNightPlaybackEventService : IHostedService
|
||||
{
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly ILogger<MovieNightPlaybackEventService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightPlaybackEventService"/> class.
|
||||
/// </summary>
|
||||
/// <param name="sessionManager">Jellyfin session manager.</param>
|
||||
/// <param name="backendClient">Backend client.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public MovieNightPlaybackEventService(
|
||||
ISessionManager sessionManager,
|
||||
MovieNightBackendClient backendClient,
|
||||
ILogger<MovieNightPlaybackEventService> logger)
|
||||
{
|
||||
_sessionManager = sessionManager;
|
||||
_backendClient = backendClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
||||
{
|
||||
if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!e.PlayedToCompletion)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = e.Users?.FirstOrDefault()?.Id.ToString("N");
|
||||
var itemId = e.Item?.Id.ToString("N");
|
||||
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var occurredAt = DateTimeOffset.UtcNow;
|
||||
var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId)
|
||||
? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}"
|
||||
: $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}";
|
||||
|
||||
var payload = new MovieNightEventPayload(
|
||||
EventId: eventId,
|
||||
EventType: "playback.stopped",
|
||||
OccurredAt: occurredAt,
|
||||
JellyfinUserId: userId,
|
||||
ItemId: itemId,
|
||||
PayloadVersion: 1,
|
||||
Payload: new Dictionary<string, object?>
|
||||
{
|
||||
["itemName"] = e.Item?.Name,
|
||||
["playSessionId"] = e.PlaySessionId,
|
||||
["positionTicks"] = e.PlaybackPositionTicks,
|
||||
["playedToCompletion"] = e.PlayedToCompletion
|
||||
});
|
||||
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "MovieNight playback event push failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using MediaBrowser.Controller.Library;
|
||||
using MediaBrowser.Controller.Entities;
|
||||
using MediaBrowser.Controller.Entities.Movies;
|
||||
using MediaBrowser.Model.Entities;
|
||||
using MediaBrowser.Model.Querying;
|
||||
using Jellyfin.Data.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.MovieNight.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for synchronizing the Jellyfin library with MovieNight.
|
||||
/// </summary>
|
||||
public class MovieNightSyncService
|
||||
{
|
||||
private readonly MovieNightBackendClient _backendClient;
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly IUserManager _userManager;
|
||||
private readonly IUserDataManager _userDataManager;
|
||||
private readonly ILogger<MovieNightSyncService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MovieNightSyncService"/> class.
|
||||
/// </summary>
|
||||
public MovieNightSyncService(
|
||||
MovieNightBackendClient backendClient,
|
||||
ILibraryManager libraryManager,
|
||||
IUserManager userManager,
|
||||
IUserDataManager userDataManager,
|
||||
ILogger<MovieNightSyncService> logger)
|
||||
{
|
||||
_backendClient = backendClient;
|
||||
_libraryManager = libraryManager;
|
||||
_userManager = userManager;
|
||||
_userDataManager = userDataManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs a full library sync.
|
||||
/// </summary>
|
||||
public async Task PerformSyncAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Starting MovieNight library sync");
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
var enabledLibraryIds = config?.EnabledLibraryIds ?? new List<string>();
|
||||
|
||||
var query = new InternalItemsQuery
|
||||
{
|
||||
IncludeItemTypes = new[] { BaseItemKind.Movie },
|
||||
Recursive = true
|
||||
};
|
||||
|
||||
if (enabledLibraryIds.Count > 0)
|
||||
{
|
||||
query.AncestorIds = enabledLibraryIds.Select(Guid.Parse).ToArray();
|
||||
}
|
||||
|
||||
var items = _libraryManager.GetItemList(query);
|
||||
var users = _userManager.Users;
|
||||
var syncItems = new List<object>();
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not Movie movie) continue;
|
||||
|
||||
var jellyfinItemId = movie.Id.ToString("N");
|
||||
|
||||
var itemData = new Dictionary<string, object?>
|
||||
{
|
||||
["jellyfinItemId"] = jellyfinItemId,
|
||||
["title"] = movie.Name,
|
||||
["originalTitle"] = movie.OriginalTitle,
|
||||
["description"] = movie.Overview,
|
||||
["year"] = movie.ProductionYear,
|
||||
["duration"] = movie.RunTimeTicks,
|
||||
["genres"] = movie.Genres,
|
||||
["posterUrl"] = $"/Items/{jellyfinItemId}/Images/Primary",
|
||||
["imdbId"] = movie.GetProviderId(MetadataProvider.Imdb),
|
||||
["tmdbId"] = movie.GetProviderId(MetadataProvider.Tmdb),
|
||||
["userStates"] = users.Select(u => {
|
||||
var userData = _userDataManager.GetUserData(u, movie);
|
||||
return new {
|
||||
jellyfinUserId = u.Id.ToString("N"),
|
||||
isViewed = userData?.Played ?? false,
|
||||
playCount = userData?.PlayCount ?? 0,
|
||||
lastPlayedAt = userData?.LastPlayedDate,
|
||||
userRating = userData?.Rating
|
||||
};
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
syncItems.Add(itemData);
|
||||
}
|
||||
|
||||
await _backendClient.SyncAsync(new { items = syncItems }, cancellationToken).ConfigureAwait(false);
|
||||
_logger.LogInformation("MovieNight library sync completed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
# MovieNight Jellyfin Plugin
|
||||
|
||||
Thin Jellyfin server plugin for bridging Jellyfin playback/sync signals to the MovieNight backend.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cd plugins/jellyfin/Jellyfin.Plugin.MovieNight
|
||||
dotnet publish -c Release
|
||||
```
|
||||
|
||||
Install the published `net9.0` plugin files into the Jellyfin data directory under `plugins/MovieNight/`, then restart Jellyfin. This build targets Jellyfin `10.11.x`.
|
||||
|
||||
## Backend Contract Used
|
||||
|
||||
Current implemented calls:
|
||||
|
||||
- `POST /api/integrations/jellyfin/sync`
|
||||
- `GET /api/integrations/jellyfin/sync-state`
|
||||
- `POST /api/integrations/jellyfin/events`
|
||||
|
||||
Event requests use JSON with:
|
||||
|
||||
- `event_id`
|
||||
- `event_type`
|
||||
- `occurred_at`
|
||||
- `jellyfin_user_id`
|
||||
- `item_id`
|
||||
- `payload_version`
|
||||
- `payload`
|
||||
|
||||
The plugin sends `X-MovieNight-Plugin-Token` when configured. Completed Jellyfin playback stop events are sent as `playback.stopped`. Transient event push failures are retried three times with the same `event_id`.
|
||||
|
||||
The config page test action posts a small synthetic event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure.
|
||||
|
||||
Sync requests use JSON with:
|
||||
|
||||
- `items`
|
||||
- `items[].jellyfinItemId`
|
||||
- `items[].title`
|
||||
- `items[].description`
|
||||
- `items[].year`
|
||||
- `items[].genres`
|
||||
- `items[].imdbId`
|
||||
- `items[].userStates`
|
||||
- `items[].userStates[].jellyfinUserId`
|
||||
- `items[].userStates[].isViewed`
|
||||
- `items[].userStates[].lastPlayedAt`
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
name: "MovieNight"
|
||||
guid: "42c72919-d6ff-4f62-bb8c-0fac39efafdb"
|
||||
version: 2
|
||||
targetAbi: "10.11.0.0"
|
||||
framework: net9.0
|
||||
owner: "movienight"
|
||||
overview: "Bridge Jellyfin events and sync triggers to MovieNight"
|
||||
description: "Thin Jellyfin plugin for MovieNight backend integration"
|
||||
category: "General"
|
||||
artifacts:
|
||||
- "Jellyfin.Plugin.MovieNight.dll"
|
||||
changelog: |-
|
||||
- Initial plugin implementation.
|
||||
@@ -2,6 +2,9 @@ package com.project.movienight.adapters.jellyfin
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.project.movienight.application.ports.output.JellyfinCatalogPort
|
||||
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
|
||||
import com.project.movienight.application.ports.output.JellyfinRemoteUser
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -11,39 +14,18 @@ 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,
|
||||
) {
|
||||
) : JellyfinCatalogPort {
|
||||
private val httpClient: HttpClient =
|
||||
HttpClient
|
||||
.newBuilder()
|
||||
.connectTimeout(Duration.ofMillis(properties.requestTimeoutMs))
|
||||
.build()
|
||||
|
||||
fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||
override fun fetchUsers(): List<JellyfinRemoteUser> =
|
||||
request("Users")
|
||||
.asItems()
|
||||
.mapNotNull { node ->
|
||||
@@ -51,7 +33,7 @@ class JellyfinApiClient(
|
||||
JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id)
|
||||
}
|
||||
|
||||
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot> =
|
||||
override 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",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.project.movienight.adapters.metrics
|
||||
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import com.project.movienight.domain.model.RecommendationEventType
|
||||
import io.micrometer.core.instrument.Counter
|
||||
@@ -11,8 +12,12 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
@Service
|
||||
class BusinessMetricsService(
|
||||
private val meterRegistry: MeterRegistry,
|
||||
) {
|
||||
) : BusinessMetricsPort {
|
||||
private val recommendationRequests: Counter = meterRegistry.counter("business_recommendation_requests_total")
|
||||
private val filmsCreated: Counter = meterRegistry.counter("business_films_created_total")
|
||||
private val filmsEdited: Counter = meterRegistry.counter("business_films_edited_total")
|
||||
private val filmsDeleted: Counter = meterRegistry.counter("business_films_deleted_total")
|
||||
private val filmsBlocked: Counter = meterRegistry.counter("business_films_blocked_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")
|
||||
@@ -33,11 +38,27 @@ class BusinessMetricsService(
|
||||
|
||||
private val backendWriteFailures: Counter = meterRegistry.counter("business_jellyfin_backend_write_failures_total")
|
||||
|
||||
fun recordRecommendationRequest() {
|
||||
override fun recordFilmCreated() {
|
||||
filmsCreated.increment()
|
||||
}
|
||||
|
||||
override fun recordFilmEdited() {
|
||||
filmsEdited.increment()
|
||||
}
|
||||
|
||||
override fun recordFilmDeleted() {
|
||||
filmsDeleted.increment()
|
||||
}
|
||||
|
||||
override fun recordFilmBlocked() {
|
||||
filmsBlocked.increment()
|
||||
}
|
||||
|
||||
override fun recordRecommendationRequest() {
|
||||
recommendationRequests.increment()
|
||||
}
|
||||
|
||||
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
|
||||
override fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType) {
|
||||
Counter
|
||||
.builder("recommendation_weights_updated_total")
|
||||
.tag("eventType", eventType.name)
|
||||
@@ -45,15 +66,15 @@ class BusinessMetricsService(
|
||||
.increment()
|
||||
}
|
||||
|
||||
fun recordRatingSubmitted() {
|
||||
override fun recordRatingSubmitted() {
|
||||
ratingsSubmitted.increment()
|
||||
}
|
||||
|
||||
fun recordLibraryEvent() {
|
||||
override fun recordLibraryEvent() {
|
||||
libraryEvents.increment()
|
||||
}
|
||||
|
||||
fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||
override fun recordJellyfinSync(summary: JellyfinSyncSummary) {
|
||||
jellyfinSyncRuns.increment()
|
||||
jellyfinSyncedUsers.increment(summary.syncedUsers.toDouble())
|
||||
jellyfinSkippedUsers.increment(summary.skippedUsers.toDouble())
|
||||
@@ -61,15 +82,15 @@ class BusinessMetricsService(
|
||||
jellyfinSyncDuration.record(summary.durationMs, java.util.concurrent.TimeUnit.MILLISECONDS)
|
||||
}
|
||||
|
||||
fun recordJellyfinSyncFailure() {
|
||||
override fun recordJellyfinSyncFailure() {
|
||||
jellyfinSyncFailures.increment()
|
||||
}
|
||||
|
||||
fun recordJellyfinUnmappedUser() {
|
||||
override fun recordJellyfinUnmappedUser() {
|
||||
jellyfinUnmappedUsersGaugeValue.incrementAndGet()
|
||||
}
|
||||
|
||||
fun recordBackendWriteFailure() {
|
||||
override fun recordBackendWriteFailure() {
|
||||
backendWriteFailures.increment()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User =
|
||||
id = id,
|
||||
name = name,
|
||||
email = email,
|
||||
library = null,
|
||||
preferences = null,
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
)
|
||||
|
||||
+39
-36
@@ -1,18 +1,18 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.sql.ResultSet
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class FilmLibraryRepository(
|
||||
class FilmLibraryEntryRepository(
|
||||
private val jdbc: JdbcTemplate,
|
||||
) : FilmLibraryRepositoryPort {
|
||||
private val filmLibraryRowMapper = { rs: ResultSet, _: Int ->
|
||||
FilmLibrary(
|
||||
) : FilmLibraryEntryRepositoryPort {
|
||||
private val rowMapper = { rs: ResultSet, _: Int ->
|
||||
FilmLibraryEntry(
|
||||
id = UUID.fromString(rs.getString("id")),
|
||||
userId = UUID.fromString(rs.getString("user_id")),
|
||||
filmId = UUID.fromString(rs.getString("film_id")),
|
||||
@@ -22,7 +22,7 @@ class FilmLibraryRepository(
|
||||
)
|
||||
}
|
||||
|
||||
override fun save(filmLibrary: FilmLibrary): FilmLibrary {
|
||||
override fun save(entry: FilmLibraryEntry): FilmLibraryEntry {
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
@@ -30,12 +30,12 @@ class FilmLibraryRepository(
|
||||
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,
|
||||
entry.userId,
|
||||
entry.filmId,
|
||||
entry.comment,
|
||||
entry.isViewed,
|
||||
entry.watchedAt,
|
||||
entry.id,
|
||||
)
|
||||
if (updatedRows == 0) {
|
||||
jdbc.update(
|
||||
@@ -43,48 +43,51 @@ class FilmLibraryRepository(
|
||||
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,
|
||||
entry.id,
|
||||
entry.userId,
|
||||
entry.filmId,
|
||||
entry.comment,
|
||||
entry.isViewed,
|
||||
entry.watchedAt,
|
||||
)
|
||||
}
|
||||
return filmLibrary
|
||||
return entry
|
||||
}
|
||||
|
||||
override fun findById(id: UUID): FilmLibrary? {
|
||||
val entries =
|
||||
jdbc.query(
|
||||
override fun findById(id: UUID): FilmLibraryEntry? =
|
||||
jdbc
|
||||
.query(
|
||||
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE id = ?",
|
||||
filmLibraryRowMapper,
|
||||
rowMapper,
|
||||
id,
|
||||
)
|
||||
return entries.firstOrNull()
|
||||
}
|
||||
).firstOrNull()
|
||||
|
||||
override fun findByUserId(userId: UUID): List<FilmLibraryEntry> =
|
||||
jdbc.query(
|
||||
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites WHERE user_id = ?",
|
||||
rowMapper,
|
||||
userId,
|
||||
)
|
||||
|
||||
override fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmLibrary? {
|
||||
val entries =
|
||||
jdbc.query(
|
||||
): FilmLibraryEntry? =
|
||||
jdbc
|
||||
.query(
|
||||
"""
|
||||
SELECT id, user_id, film_id, comment, is_viewed, watched_at
|
||||
FROM favorites WHERE user_id = ? AND film_id = ?
|
||||
""".trimIndent(),
|
||||
filmLibraryRowMapper,
|
||||
rowMapper,
|
||||
userId,
|
||||
filmId,
|
||||
)
|
||||
return entries.firstOrNull()
|
||||
}
|
||||
).firstOrNull()
|
||||
|
||||
override fun findAll(): List<FilmLibrary> =
|
||||
override fun findAll(): List<FilmLibraryEntry> =
|
||||
jdbc.query(
|
||||
"SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites",
|
||||
filmLibraryRowMapper,
|
||||
rowMapper,
|
||||
)
|
||||
|
||||
override fun deleteById(id: UUID) {
|
||||
@@ -159,32 +159,6 @@ class FilmRepository(
|
||||
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(
|
||||
"""
|
||||
|
||||
+12
-24
@@ -1,5 +1,7 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.application.ports.output.JellyfinEventRecord
|
||||
import com.project.movienight.application.ports.output.JellyfinEventStorePort
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate
|
||||
import org.springframework.stereotype.Repository
|
||||
@@ -7,16 +9,8 @@ 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 {
|
||||
) : JellyfinEventStorePort {
|
||||
override fun save(event: JellyfinEventRecord): Boolean {
|
||||
val sql =
|
||||
"""
|
||||
INSERT INTO jellyfin_events(event_id, server_id, event_type, occurred_at, jellyfin_user_id, jellyfin_item_id, payload)
|
||||
@@ -26,20 +20,14 @@ class JellyfinEventRepository(
|
||||
|
||||
val params =
|
||||
MapSqlParameterSource()
|
||||
.addValue("eventId", eventId)
|
||||
.addValue("serverId", serverId)
|
||||
.addValue("eventType", eventType)
|
||||
.addValue("occurredAt", occurredAt)
|
||||
.addValue("jellyfinUserId", jellyfinUserId)
|
||||
.addValue("jellyfinItemId", jellyfinItemId)
|
||||
.addValue("payload", payload)
|
||||
.addValue("eventId", event.eventId)
|
||||
.addValue("serverId", event.serverId)
|
||||
.addValue("eventType", event.eventType)
|
||||
.addValue("occurredAt", event.occurredAt)
|
||||
.addValue("jellyfinUserId", event.jellyfinUserId)
|
||||
.addValue("jellyfinItemId", event.jellyfinItemId)
|
||||
.addValue("payload", event.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)
|
||||
return jdbc.update(sql, params) == 1
|
||||
}
|
||||
}
|
||||
|
||||
+103
-5
@@ -4,6 +4,7 @@ import com.project.movienight.adapters.persistence.entity.UserEntity
|
||||
import com.project.movienight.adapters.persistence.entity.toDomain
|
||||
import com.project.movienight.adapters.persistence.entity.toEntity
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import com.project.movienight.domain.model.AuthProvider
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.springframework.jdbc.core.JdbcTemplate
|
||||
@@ -32,11 +33,9 @@ class UserRepository(
|
||||
|
||||
val entity =
|
||||
if (existingUser != null) {
|
||||
val existingEntity = existingUser.toEntity()
|
||||
user.toEntity(
|
||||
provider = existingEntity.provider?.let { AuthProvider.valueOf(it) },
|
||||
providerId = existingEntity.providerId,
|
||||
createdAt = existingEntity.createdAt,
|
||||
provider = findProviderById(user.id),
|
||||
providerId = findProviderIdById(user.id),
|
||||
)
|
||||
} else {
|
||||
user.toEntity()
|
||||
@@ -75,6 +74,70 @@ class UserRepository(
|
||||
return user
|
||||
}
|
||||
|
||||
override fun createOAuthUser(
|
||||
user: User,
|
||||
provider: AuthProvider,
|
||||
providerId: String,
|
||||
): User {
|
||||
val entity = user.toEntity(provider = provider, providerId = providerId)
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE users
|
||||
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, jellyfin_user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
entity.id,
|
||||
entity.name,
|
||||
entity.email,
|
||||
entity.provider,
|
||||
entity.providerId,
|
||||
entity.jellyfinUserId,
|
||||
entity.createdAt,
|
||||
)
|
||||
}
|
||||
|
||||
return findById(user.id) ?: user
|
||||
}
|
||||
|
||||
override fun linkOAuthAccount(
|
||||
userId: UUID,
|
||||
provider: AuthProvider,
|
||||
providerId: String,
|
||||
): User {
|
||||
val updatedRows =
|
||||
jdbc.update(
|
||||
"""
|
||||
UPDATE users
|
||||
SET provider = ?, provider_id = ?
|
||||
WHERE id = ?
|
||||
""".trimIndent(),
|
||||
provider.name,
|
||||
providerId,
|
||||
userId,
|
||||
)
|
||||
|
||||
if (updatedRows == 0) {
|
||||
throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||
}
|
||||
|
||||
return findById(userId) ?: throw EntityNotFoundException(entity = "User", id = userId.toString())
|
||||
}
|
||||
|
||||
override fun findById(id: UUID): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
@@ -88,13 +151,31 @@ class UserRepository(
|
||||
override fun findByEmail(email: String): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"SELECT id, name, email, provider, provider_id, created_at FROM users WHERE email = ?",
|
||||
"""
|
||||
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
|
||||
FROM users
|
||||
WHERE email = ?
|
||||
""".trimIndent(),
|
||||
userEntityRowMapper,
|
||||
email,
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
override fun findByJellyfinUserId(jellyfinUserId: String): User? {
|
||||
val entities =
|
||||
jdbc.query(
|
||||
"""
|
||||
SELECT id, name, email, provider, provider_id, jellyfin_user_id, created_at
|
||||
FROM users
|
||||
WHERE jellyfin_user_id = ?
|
||||
""".trimIndent(),
|
||||
userEntityRowMapper,
|
||||
jellyfinUserId,
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
override fun findAll(): List<User> =
|
||||
jdbc
|
||||
.query(
|
||||
@@ -122,4 +203,21 @@ class UserRepository(
|
||||
)
|
||||
return entities.firstOrNull()?.toDomain()
|
||||
}
|
||||
|
||||
private fun findProviderById(id: UUID): AuthProvider? =
|
||||
jdbc
|
||||
.query(
|
||||
"SELECT provider FROM users WHERE id = ?",
|
||||
{ rs: ResultSet, _: Int -> rs.getString("provider") },
|
||||
id,
|
||||
).firstOrNull()
|
||||
?.let { AuthProvider.valueOf(it) }
|
||||
|
||||
private fun findProviderIdById(id: UUID): String? =
|
||||
jdbc
|
||||
.query(
|
||||
"SELECT provider_id FROM users WHERE id = ?",
|
||||
{ rs: ResultSet, _: Int -> rs.getString("provider_id") },
|
||||
id,
|
||||
).firstOrNull()
|
||||
}
|
||||
|
||||
+10
-15
@@ -1,7 +1,5 @@
|
||||
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
|
||||
@@ -59,12 +57,11 @@ class CustomOAuth2UserService(
|
||||
|
||||
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())
|
||||
userRepository.linkOAuthAccount(
|
||||
userId = userByEmail.id,
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
} else {
|
||||
log.debug("Creating new user for provider: {}", userInfo.getProvider())
|
||||
val newUser =
|
||||
@@ -72,14 +69,12 @@ class CustomOAuth2UserService(
|
||||
id = idGenerator.generateId(),
|
||||
name = userInfo.getName(),
|
||||
email = userInfo.getEmail(),
|
||||
library = null,
|
||||
)
|
||||
val entity =
|
||||
newUser.toEntity(
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
userRepository.save(entity.toDomain())
|
||||
userRepository.createOAuthUser(
|
||||
user = newUser,
|
||||
provider = provider,
|
||||
providerId = userInfo.getProviderId(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,11 @@ class SecurityConfiguration(
|
||||
auth
|
||||
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
|
||||
.permitAll()
|
||||
.requestMatchers(
|
||||
"/api/integrations/jellyfin/events",
|
||||
"/api/integrations/jellyfin/sync",
|
||||
"/api/integrations/jellyfin/sync-state",
|
||||
).permitAll()
|
||||
.requestMatchers("/api/users/me")
|
||||
.authenticated()
|
||||
.requestMatchers("/api/**")
|
||||
|
||||
@@ -6,9 +6,12 @@ import com.project.movienight.domain.exception.EntityNotFoundException
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.slf4j.MDC
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.MethodArgumentNotValidException
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
@RestControllerAdvice
|
||||
class ApiExceptionHandler {
|
||||
@@ -50,6 +53,44 @@ class ApiExceptionHandler {
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(MethodArgumentNotValidException::class)
|
||||
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||
fun handleValidationException(exception: MethodArgumentNotValidException): ErrorResponse {
|
||||
val traceId = currentTraceId()
|
||||
val details =
|
||||
exception
|
||||
.bindingResult
|
||||
.fieldErrors
|
||||
.joinToString("; ") { error -> "${error.field}: ${error.defaultMessage}" }
|
||||
.ifBlank { "Invalid request" }
|
||||
log.warn("Validation error: traceId='{}', message='{}'", traceId, details)
|
||||
|
||||
return ErrorResponse(
|
||||
message = details,
|
||||
traceId = traceId,
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException::class)
|
||||
fun handleResponseStatusException(exception: ResponseStatusException): ResponseEntity<ErrorResponse> {
|
||||
val traceId = currentTraceId()
|
||||
log.warn(
|
||||
"HTTP error: traceId='{}', status='{}', message='{}'",
|
||||
traceId,
|
||||
exception.statusCode,
|
||||
exception.reason,
|
||||
)
|
||||
|
||||
return ResponseEntity
|
||||
.status(exception.statusCode)
|
||||
.body(
|
||||
ErrorResponse(
|
||||
message = exception.reason ?: exception.message,
|
||||
traceId = traceId,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception::class)
|
||||
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
fun handleUnexpectedException(exception: Exception): ErrorResponse {
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.domain.exception.DomainException
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
|
||||
fun parseContentType(value: String): ContentType =
|
||||
runCatching { ContentType.valueOf(value.uppercase()) }
|
||||
.getOrElse { throw DomainException("Unsupported content type: $value") }
|
||||
|
||||
fun parseOptionalContentType(value: String?): ContentType? = value?.let { parseContentType(it) }
|
||||
@@ -4,13 +4,9 @@ import com.project.movienight.adapters.web.dto.request.CreateFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.request.EditFilmRequest
|
||||
import com.project.movienight.adapters.web.dto.response.FilmResponse
|
||||
import com.project.movienight.application.ports.input.CreateFilmCommand
|
||||
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.input.FilmUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.http.ResponseEntity
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
@@ -25,33 +21,22 @@ 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,
|
||||
private val filmUseCase: FilmUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun create(
|
||||
@RequestBody request: CreateFilmRequest,
|
||||
@Valid @RequestBody request: CreateFilmRequest,
|
||||
): FilmResponse =
|
||||
FilmResponse.fromDomain(
|
||||
createFilmUseCase.create(
|
||||
filmUseCase.create(
|
||||
CreateFilmCommand(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
contentType = request.contentType.toContentTypeOrFilm(),
|
||||
contentType = parseContentType(request.contentType),
|
||||
releaseYear = request.releaseYear,
|
||||
genres = request.genres,
|
||||
cast = request.cast,
|
||||
@@ -68,16 +53,16 @@ class FilmController(
|
||||
@PatchMapping("/{id}")
|
||||
fun edit(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody request: EditFilmRequest,
|
||||
@Valid @RequestBody request: EditFilmRequest,
|
||||
): FilmResponse =
|
||||
FilmResponse.fromDomain(
|
||||
editFilmUseCase.edit(
|
||||
filmUseCase.edit(
|
||||
id = id,
|
||||
command =
|
||||
EditFilmCommand(
|
||||
title = request.title,
|
||||
description = request.description,
|
||||
contentType = request.contentType.toContentTypeOrFilm(),
|
||||
contentType = parseContentType(request.contentType),
|
||||
releaseYear = request.releaseYear,
|
||||
genres = request.genres,
|
||||
cast = request.cast,
|
||||
@@ -95,21 +80,21 @@ class FilmController(
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
) = deleteFilmUseCase.delete(id)
|
||||
) = filmUseCase.delete(id)
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getById(
|
||||
@PathVariable id: UUID,
|
||||
): FilmResponse = FilmResponse.fromDomain(getFilmByIdUseCase.getById(id))
|
||||
): FilmResponse = FilmResponse.fromDomain(filmUseCase.getById(id))
|
||||
|
||||
@GetMapping
|
||||
fun getAll(): List<FilmResponse> = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||
fun getAll(): List<FilmResponse> = filmUseCase.getAll().map { FilmResponse.fromDomain(it) }
|
||||
|
||||
@GetMapping("/search")
|
||||
fun searchByTitle(
|
||||
@RequestParam title: String,
|
||||
): ResponseEntity<FilmResponse> {
|
||||
val film = searchFilmByTitleUseCase.searchByTitle(title)
|
||||
val film = filmUseCase.searchByTitle(title)
|
||||
return if (film != null) {
|
||||
ResponseEntity.ok(FilmResponse.fromDomain(film))
|
||||
} else {
|
||||
|
||||
@@ -1,28 +1,16 @@
|
||||
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.FilmLibraryEntryResponse
|
||||
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.FilmLibraryUseCase
|
||||
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
|
||||
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
|
||||
@@ -31,52 +19,29 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/library")
|
||||
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,
|
||||
private val filmLibraryUseCase: FilmLibraryUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun create(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: CreateFilmLibraryRequest,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
createFilmLibraryUseCase.create(
|
||||
CreateFilmLibraryCommand(
|
||||
userId = userId,
|
||||
name = request.name,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun get(
|
||||
@PathVariable userId: UUID,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
getFilmLibraryUseCase.getLibrary(
|
||||
GetFilmLibraryQuery(userId = userId),
|
||||
),
|
||||
)
|
||||
|
||||
@GetMapping("/entries")
|
||||
fun list(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmLibraryResponse> = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) }
|
||||
): List<FilmLibraryEntryResponse> =
|
||||
filmLibraryUseCase
|
||||
.list(userId)
|
||||
.map { entry -> FilmLibraryEntryResponse.fromDomain(entry) }
|
||||
|
||||
@GetMapping("/entries")
|
||||
fun listEntries(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmLibraryEntryResponse> = list(userId)
|
||||
|
||||
@PostMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun addFilm(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
addFilmToLibraryUseCase.addFilm(
|
||||
): FilmLibraryEntryResponse =
|
||||
FilmLibraryEntryResponse.fromDomain(
|
||||
filmLibraryUseCase.addFilm(
|
||||
AddFilmToLibraryCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
@@ -88,9 +53,9 @@ class FilmLibraryController(
|
||||
fun markViewed(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
): FilmLibraryResponse =
|
||||
FilmLibraryResponse.fromDomain(
|
||||
markFilmViewedUseCase.markViewed(
|
||||
): FilmLibraryEntryResponse =
|
||||
FilmLibraryEntryResponse.fromDomain(
|
||||
filmLibraryUseCase.markViewed(
|
||||
MarkFilmViewedCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
@@ -104,7 +69,7 @@ class FilmLibraryController(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
) {
|
||||
removeFilmFromLibraryUseCase.removeFilm(
|
||||
filmLibraryUseCase.removeFilm(
|
||||
RemoveFilmFromLibraryCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
@@ -115,27 +80,5 @@ 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) }
|
||||
}
|
||||
): List<FilmResponse> = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ 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.FilmRatingUseCase
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
@@ -18,18 +18,17 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/ratings")
|
||||
class FilmRatingController(
|
||||
private val rateFilmUseCase: RateFilmUseCase,
|
||||
private val getFilmRatingsUseCase: GetFilmRatingsUseCase,
|
||||
private val filmRatingUseCase: FilmRatingUseCase,
|
||||
) {
|
||||
@PostMapping("/films/{filmId}")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun rate(
|
||||
@PathVariable userId: UUID,
|
||||
@PathVariable filmId: UUID,
|
||||
@RequestBody request: RateFilmRequest,
|
||||
@Valid @RequestBody request: RateFilmRequest,
|
||||
): FilmRatingResponse =
|
||||
FilmRatingResponse.fromDomain(
|
||||
rateFilmUseCase.rate(
|
||||
filmRatingUseCase.rate(
|
||||
RateFilmCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
@@ -42,5 +41,5 @@ class FilmRatingController(
|
||||
@GetMapping
|
||||
fun list(
|
||||
@PathVariable userId: UUID,
|
||||
): List<FilmRatingResponse> = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||
): List<FilmRatingResponse> = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
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.application.ports.input.HandleJellyfinEventCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinEventUseCase
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import jakarta.validation.Valid
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
@@ -11,12 +13,11 @@ 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 jellyfinEventUseCase: JellyfinEventUseCase,
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
|
||||
@@ -25,17 +26,10 @@ class JellyfinEventsController(
|
||||
@ResponseStatus(HttpStatus.OK)
|
||||
fun receiveEvent(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@RequestBody request: JellyfinEventRequest,
|
||||
@Valid @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")
|
||||
}
|
||||
}
|
||||
requireJellyfinIntegrationEnabled(properties)
|
||||
requireJellyfinPluginToken(properties, token)
|
||||
|
||||
log.debug(
|
||||
"Received Jellyfin event {} for user {} item {}",
|
||||
@@ -43,14 +37,16 @@ class JellyfinEventsController(
|
||||
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,
|
||||
jellyfinEventUseCase.handle(
|
||||
HandleJellyfinEventCommand(
|
||||
eventId = request.eventId,
|
||||
serverId = null,
|
||||
eventType = request.eventType,
|
||||
occurredAt = request.occurredAt,
|
||||
jellyfinUserId = request.jellyfinUserId,
|
||||
itemId = request.itemId,
|
||||
payload = request.payload,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.web.server.ResponseStatusException
|
||||
|
||||
fun requireJellyfinIntegrationEnabled(properties: JellyfinIntegrationProperties) {
|
||||
if (!properties.enabled) {
|
||||
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
|
||||
}
|
||||
}
|
||||
|
||||
fun requireJellyfinPluginToken(
|
||||
properties: JellyfinIntegrationProperties,
|
||||
token: String?,
|
||||
) {
|
||||
if (properties.pluginToken.isNotBlank() && token != properties.pluginToken) {
|
||||
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,43 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.application.services.JellyfinSyncService
|
||||
import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
|
||||
import com.project.movienight.config.JellyfinIntegrationProperties
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
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.RestController
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/integrations/jellyfin")
|
||||
class JellyfinSyncController(
|
||||
private val jellyfinSyncService: JellyfinSyncService,
|
||||
private val jellyfinSyncUseCase: JellyfinSyncUseCase,
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
) {
|
||||
@PostMapping("/sync")
|
||||
fun syncNow(): JellyfinSyncSummary = jellyfinSyncService.syncNow()
|
||||
fun syncFromPlugin(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
@Valid @RequestBody request: JellyfinSyncRequest,
|
||||
): JellyfinSyncSummary {
|
||||
requireJellyfinIntegrationEnabled(properties)
|
||||
requireJellyfinPluginToken(properties, token)
|
||||
return jellyfinSyncUseCase.syncFromPlugin(request.toCommand())
|
||||
}
|
||||
|
||||
@PostMapping("/pull-sync")
|
||||
fun pullSyncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow()
|
||||
|
||||
@GetMapping("/sync-state")
|
||||
fun syncState(): List<JellyfinSyncState> = jellyfinSyncService.getSyncStates()
|
||||
fun syncState(
|
||||
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
|
||||
): List<JellyfinSyncState> {
|
||||
requireJellyfinIntegrationEnabled(properties)
|
||||
requireJellyfinPluginToken(properties, token)
|
||||
return jellyfinSyncUseCase.getSyncStates()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ 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
|
||||
@@ -40,7 +39,7 @@ class RecommendationController(
|
||||
.recommend(
|
||||
RecommendationQuery(
|
||||
userId = userId,
|
||||
contentType = contentType?.let { runCatching { ContentType.valueOf(it.uppercase()) }.getOrNull() },
|
||||
contentType = parseOptionalContentType(contentType),
|
||||
mood = mood,
|
||||
libraryOnly = libraryOnly,
|
||||
limit = limit,
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.adapters.security.UserPrincipal
|
||||
import com.project.movienight.adapters.web.dto.request.CreateUserRequest
|
||||
import com.project.movienight.adapters.web.dto.request.EditUserRequest
|
||||
import com.project.movienight.adapters.web.dto.response.UserResponse
|
||||
import com.project.movienight.application.ports.input.CreateUserCommand
|
||||
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.input.UserUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PatchMapping
|
||||
@@ -25,19 +24,15 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/users")
|
||||
class UserController(
|
||||
private val createUserUseCase: CreateUserUseCase,
|
||||
private val editUserUseCase: EditUserUseCase,
|
||||
private val deleteUserUseCase: DeleteUserUseCase,
|
||||
private val getUserByIdUseCase: GetUserByIdUseCase,
|
||||
private val getAllUsersUseCase: GetAllUsersUseCase,
|
||||
private val userUseCase: UserUseCase,
|
||||
) {
|
||||
@PostMapping
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun create(
|
||||
@RequestBody request: CreateUserRequest,
|
||||
@Valid @RequestBody request: CreateUserRequest,
|
||||
): UserResponse =
|
||||
UserResponse.fromDomain(
|
||||
createUserUseCase.create(
|
||||
userUseCase.create(
|
||||
CreateUserCommand(
|
||||
name = request.name,
|
||||
email = request.email,
|
||||
@@ -46,20 +41,25 @@ class UserController(
|
||||
)
|
||||
|
||||
@GetMapping
|
||||
fun getAll(): List<UserResponse> = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||
fun getAll(): List<UserResponse> = userUseCase.getAll().map { UserResponse.fromDomain(it) }
|
||||
|
||||
@GetMapping("/me")
|
||||
fun getMe(
|
||||
@AuthenticationPrincipal principal: UserPrincipal,
|
||||
): UserResponse = UserResponse.fromDomain(userUseCase.getById(principal.getId()))
|
||||
|
||||
@GetMapping("/{id}")
|
||||
fun getById(
|
||||
@PathVariable id: UUID,
|
||||
): UserResponse = UserResponse.fromDomain(getUserByIdUseCase.getById(id))
|
||||
): UserResponse = UserResponse.fromDomain(userUseCase.getById(id))
|
||||
|
||||
@PatchMapping("/{id}")
|
||||
fun edit(
|
||||
@PathVariable id: UUID,
|
||||
@RequestBody request: EditUserRequest,
|
||||
@Valid @RequestBody request: EditUserRequest,
|
||||
): UserResponse =
|
||||
UserResponse.fromDomain(
|
||||
editUserUseCase.edit(
|
||||
userUseCase.edit(
|
||||
id = id,
|
||||
command =
|
||||
EditUserCommand(
|
||||
@@ -73,5 +73,5 @@ class UserController(
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun delete(
|
||||
@PathVariable id: UUID,
|
||||
) = deleteUserUseCase.delete(id)
|
||||
) = userUseCase.delete(id)
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@ 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 com.project.movienight.application.ports.input.UserPreferencesUseCase
|
||||
import jakarta.validation.Valid
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PutMapping
|
||||
@@ -17,16 +16,15 @@ import java.util.UUID
|
||||
@RestController
|
||||
@RequestMapping("/api/users/{userId}/preferences")
|
||||
class UserPreferencesController(
|
||||
private val upsertUserPreferencesUseCase: UpsertUserPreferencesUseCase,
|
||||
private val getUserPreferencesUseCase: GetUserPreferencesUseCase,
|
||||
private val userPreferencesUseCase: UserPreferencesUseCase,
|
||||
) {
|
||||
@PutMapping
|
||||
fun upsert(
|
||||
@PathVariable userId: UUID,
|
||||
@RequestBody request: UpsertUserPreferencesRequest,
|
||||
@Valid @RequestBody request: UpsertUserPreferencesRequest,
|
||||
): UserPreferencesResponse =
|
||||
UserPreferencesResponse.fromDomain(
|
||||
upsertUserPreferencesUseCase.upsert(
|
||||
userPreferencesUseCase.upsert(
|
||||
UpsertUserPreferencesCommand(
|
||||
userId = userId,
|
||||
weightedGenres = request.weightedGenres,
|
||||
@@ -35,13 +33,7 @@ class UserPreferencesController(
|
||||
castAndDirectors = request.castAndDirectors,
|
||||
moods = request.moods,
|
||||
contentTypes =
|
||||
request.contentTypes.mapNotNull {
|
||||
runCatching {
|
||||
ContentType.valueOf(
|
||||
it,
|
||||
)
|
||||
}.getOrNull()
|
||||
},
|
||||
request.contentTypes.map { parseContentType(it) },
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -49,5 +41,5 @@ class UserPreferencesController(
|
||||
@GetMapping
|
||||
fun get(
|
||||
@PathVariable userId: UUID,
|
||||
): UserPreferencesResponse? = getUserPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||
): UserPreferencesResponse? = userPreferencesUseCase.get(userId)?.let { UserPreferencesResponse.fromDomain(it) }
|
||||
}
|
||||
|
||||
-5
@@ -1,5 +0,0 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
data class CreateFilmLibraryRequest(
|
||||
val name: String = "My films",
|
||||
)
|
||||
@@ -1,14 +1,29 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.Max
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class CreateFilmRequest(
|
||||
@field:NotBlank
|
||||
@field:Size(max = 255)
|
||||
val title: String,
|
||||
@field:NotBlank
|
||||
val description: String,
|
||||
@field:NotBlank
|
||||
val contentType: String = "FILM",
|
||||
@field:Min(1888)
|
||||
@field:Max(3000)
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val imdbRating: Double? = null,
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.Email
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class CreateUserRequest(
|
||||
@field:NotBlank
|
||||
@field:Size(max = 255)
|
||||
val name: String,
|
||||
@field:Email
|
||||
@field:NotBlank
|
||||
@field:Size(max = 320)
|
||||
val email: String,
|
||||
)
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.Max
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class EditFilmRequest(
|
||||
@field:NotBlank
|
||||
@field:Size(max = 255)
|
||||
val title: String,
|
||||
@field:NotBlank
|
||||
val description: String,
|
||||
@field:NotBlank
|
||||
val contentType: String = "FILM",
|
||||
@field:Min(1888)
|
||||
@field:Max(3000)
|
||||
val releaseYear: Int? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val cast: List<String> = emptyList(),
|
||||
val directors: List<String> = emptyList(),
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val imdbRating: Double? = null,
|
||||
@field:Min(0)
|
||||
@field:Max(10)
|
||||
val platformRating: Double? = null,
|
||||
val externalUrl: String? = null,
|
||||
val jellyfinItemId: String? = null,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class EditUserRequest(
|
||||
@field:NotBlank
|
||||
@field:Size(max = 255)
|
||||
val name: String,
|
||||
@field:Size(max = 255)
|
||||
val jellyfinUserId: String? = null,
|
||||
)
|
||||
|
||||
+5
@@ -1,18 +1,23 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonProperty
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class JellyfinEventRequest(
|
||||
@JsonProperty("event_id")
|
||||
@field:NotBlank
|
||||
val eventId: String,
|
||||
@JsonProperty("event_type")
|
||||
@field:NotBlank
|
||||
val eventType: String,
|
||||
@JsonProperty("occurred_at")
|
||||
val occurredAt: OffsetDateTime,
|
||||
@JsonProperty("jellyfin_user_id")
|
||||
@field:NotBlank
|
||||
val jellyfinUserId: String,
|
||||
@JsonProperty("item_id")
|
||||
@field:NotBlank
|
||||
val itemId: String,
|
||||
@JsonProperty("payload_version")
|
||||
val payloadVersion: Int = 1,
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import com.project.movienight.application.ports.input.JellyfinPluginSyncCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinPluginSyncItem
|
||||
import com.project.movienight.application.ports.input.JellyfinPluginUserState
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
data class JellyfinSyncRequest(
|
||||
@field:Valid
|
||||
val items: List<JellyfinSyncItemRequest> = emptyList(),
|
||||
) {
|
||||
fun toCommand(): JellyfinPluginSyncCommand =
|
||||
JellyfinPluginSyncCommand(
|
||||
items = items.map { it.toCommand() },
|
||||
)
|
||||
}
|
||||
|
||||
data class JellyfinSyncItemRequest(
|
||||
@field:NotBlank
|
||||
val jellyfinItemId: String,
|
||||
val title: String?,
|
||||
val description: String?,
|
||||
val year: Int?,
|
||||
val genres: List<String> = emptyList(),
|
||||
val imdbId: String?,
|
||||
@field:Valid
|
||||
val userStates: List<JellyfinUserStateRequest> = emptyList(),
|
||||
) {
|
||||
fun toCommand(): JellyfinPluginSyncItem =
|
||||
JellyfinPluginSyncItem(
|
||||
jellyfinItemId = jellyfinItemId,
|
||||
title = title?.takeIf { it.isNotBlank() } ?: jellyfinItemId,
|
||||
description = description,
|
||||
year = year,
|
||||
genres = genres,
|
||||
imdbId = imdbId,
|
||||
userStates = userStates.map { it.toCommand() },
|
||||
)
|
||||
}
|
||||
|
||||
data class JellyfinUserStateRequest(
|
||||
@field:NotBlank
|
||||
val jellyfinUserId: String,
|
||||
val isViewed: Boolean = false,
|
||||
val lastPlayedAt: OffsetDateTime? = null,
|
||||
) {
|
||||
fun toCommand(): JellyfinPluginUserState =
|
||||
JellyfinPluginUserState(
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
isViewed = isViewed,
|
||||
lastPlayedAt = lastPlayedAt,
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
package com.project.movienight.adapters.web.dto.request
|
||||
|
||||
import jakarta.validation.constraints.Max
|
||||
import jakarta.validation.constraints.Min
|
||||
import jakarta.validation.constraints.Size
|
||||
|
||||
data class RateFilmRequest(
|
||||
@field:Min(1)
|
||||
@field:Max(10)
|
||||
val score: Int,
|
||||
@field:Size(max = 2048)
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class FilmLibraryEntryResponse(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val comment: String?,
|
||||
val isViewed: Boolean,
|
||||
val watchedAt: LocalDateTime?,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(entry: FilmLibraryEntry): FilmLibraryEntryResponse =
|
||||
FilmLibraryEntryResponse(
|
||||
id = entry.id,
|
||||
userId = entry.userId,
|
||||
filmId = entry.filmId,
|
||||
comment = entry.comment,
|
||||
isViewed = entry.isViewed,
|
||||
watchedAt = entry.watchedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
package com.project.movienight.adapters.web.dto.response
|
||||
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import java.util.UUID
|
||||
|
||||
data class FilmLibraryResponse(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val comment: String?,
|
||||
val isViewed: Boolean,
|
||||
val watchedAt: java.time.LocalDateTime?,
|
||||
) {
|
||||
companion object {
|
||||
fun fromDomain(filmLibrary: FilmLibrary): FilmLibraryResponse =
|
||||
FilmLibraryResponse(
|
||||
id = filmLibrary.id,
|
||||
userId = filmLibrary.userId,
|
||||
filmId = filmLibrary.filmId,
|
||||
comment = filmLibrary.comment,
|
||||
isViewed = filmLibrary.isViewed,
|
||||
watchedAt = filmLibrary.watchedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
+11
-31
@@ -1,20 +1,20 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
interface CreateFilmLibraryUseCase {
|
||||
fun create(command: CreateFilmLibraryCommand): FilmLibrary
|
||||
}
|
||||
interface FilmLibraryUseCase {
|
||||
fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry
|
||||
|
||||
data class CreateFilmLibraryCommand(
|
||||
val userId: UUID,
|
||||
val name: String = "Мои фильмы",
|
||||
)
|
||||
fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry
|
||||
|
||||
interface AddFilmToLibraryUseCase {
|
||||
fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary
|
||||
fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry
|
||||
|
||||
fun list(userId: UUID): List<FilmLibraryEntry>
|
||||
|
||||
fun listAvailableFilms(userId: UUID): List<Film>
|
||||
}
|
||||
|
||||
data class AddFilmToLibraryCommand(
|
||||
@@ -22,34 +22,14 @@ 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
|
||||
}
|
||||
|
||||
data class RemoveFilmFromLibraryCommand(
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
val libraryId: UUID? = null,
|
||||
val entryId: UUID? = null,
|
||||
)
|
||||
|
||||
interface GetFilmLibraryUseCase {
|
||||
fun getLibrary(query: GetFilmLibraryQuery): FilmLibrary
|
||||
}
|
||||
|
||||
data class GetFilmLibraryQuery(
|
||||
val userId: UUID,
|
||||
)
|
||||
|
||||
interface ListFilmLibraryEntriesUseCase {
|
||||
fun list(userId: UUID): List<FilmLibrary>
|
||||
}
|
||||
|
||||
@@ -3,8 +3,10 @@ package com.project.movienight.application.ports.input
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import java.util.UUID
|
||||
|
||||
interface RateFilmUseCase {
|
||||
interface FilmRatingUseCase {
|
||||
fun rate(command: RateFilmCommand): FilmRating
|
||||
|
||||
fun getRatings(userId: UUID): List<FilmRating>
|
||||
}
|
||||
|
||||
data class RateFilmCommand(
|
||||
@@ -13,7 +15,3 @@ data class RateFilmCommand(
|
||||
val score: Int,
|
||||
val note: String? = null,
|
||||
)
|
||||
|
||||
interface GetFilmRatingsUseCase {
|
||||
fun getRatings(userId: UUID): List<FilmRating>
|
||||
}
|
||||
|
||||
@@ -4,8 +4,21 @@ import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.Film
|
||||
import java.util.UUID
|
||||
|
||||
interface CreateFilmUseCase {
|
||||
interface FilmUseCase {
|
||||
fun create(command: CreateFilmCommand): Film
|
||||
|
||||
fun edit(
|
||||
id: UUID,
|
||||
command: EditFilmCommand,
|
||||
): Film
|
||||
|
||||
fun delete(id: UUID)
|
||||
|
||||
fun getById(id: UUID): Film
|
||||
|
||||
fun getAll(): List<Film>
|
||||
|
||||
fun searchByTitle(title: String): Film?
|
||||
}
|
||||
|
||||
data class CreateFilmCommand(
|
||||
@@ -23,13 +36,6 @@ data class CreateFilmCommand(
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
interface EditFilmUseCase {
|
||||
fun edit(
|
||||
id: UUID,
|
||||
command: EditFilmCommand,
|
||||
): Film
|
||||
}
|
||||
|
||||
data class EditFilmCommand(
|
||||
val title: String,
|
||||
val description: String,
|
||||
@@ -44,19 +50,3 @@ data class EditFilmCommand(
|
||||
val jellyfinItemId: String? = null,
|
||||
val jellyfinLibraryId: String? = null,
|
||||
)
|
||||
|
||||
interface DeleteFilmUseCase {
|
||||
fun delete(id: UUID)
|
||||
}
|
||||
|
||||
interface GetFilmByIdUseCase {
|
||||
fun getById(id: UUID): Film
|
||||
}
|
||||
|
||||
interface GetAllFilmsUseCase {
|
||||
fun getAll(): List<Film>
|
||||
}
|
||||
|
||||
interface SearchFilmByTitleUseCase {
|
||||
fun searchByTitle(title: String): Film?
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.project.movienight.application.ports.input
|
||||
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
interface JellyfinEventUseCase {
|
||||
fun handle(command: HandleJellyfinEventCommand)
|
||||
}
|
||||
|
||||
data class HandleJellyfinEventCommand(
|
||||
val eventId: String,
|
||||
val serverId: String?,
|
||||
val eventType: String,
|
||||
val occurredAt: OffsetDateTime,
|
||||
val jellyfinUserId: String,
|
||||
val itemId: String,
|
||||
val payload: Map<String, Any>?,
|
||||
)
|
||||
|
||||
interface JellyfinSyncUseCase {
|
||||
fun syncNow(): JellyfinSyncSummary
|
||||
|
||||
fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary
|
||||
|
||||
fun getSyncStates(): List<JellyfinSyncState>
|
||||
}
|
||||
|
||||
data class JellyfinPluginSyncCommand(
|
||||
val items: List<JellyfinPluginSyncItem>,
|
||||
)
|
||||
|
||||
data class JellyfinPluginSyncItem(
|
||||
val jellyfinItemId: String,
|
||||
val title: String,
|
||||
val description: String?,
|
||||
val year: Int?,
|
||||
val genres: List<String>,
|
||||
val imdbId: String?,
|
||||
val userStates: List<JellyfinPluginUserState>,
|
||||
)
|
||||
|
||||
data class JellyfinPluginUserState(
|
||||
val jellyfinUserId: String,
|
||||
val isViewed: Boolean,
|
||||
val lastPlayedAt: OffsetDateTime?,
|
||||
)
|
||||
+3
-5
@@ -4,8 +4,10 @@ import com.project.movienight.domain.model.ContentType
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import java.util.UUID
|
||||
|
||||
interface UpsertUserPreferencesUseCase {
|
||||
interface UserPreferencesUseCase {
|
||||
fun upsert(command: UpsertUserPreferencesCommand): UserPreferences
|
||||
|
||||
fun get(userId: UUID): UserPreferences?
|
||||
}
|
||||
|
||||
data class UpsertUserPreferencesCommand(
|
||||
@@ -17,7 +19,3 @@ data class UpsertUserPreferencesCommand(
|
||||
val moods: List<String> = emptyList(),
|
||||
val contentTypes: List<ContentType> = emptyList(),
|
||||
)
|
||||
|
||||
interface GetUserPreferencesUseCase {
|
||||
fun get(userId: UUID): UserPreferences?
|
||||
}
|
||||
|
||||
@@ -3,8 +3,19 @@ package com.project.movienight.application.ports.input
|
||||
import com.project.movienight.domain.model.User
|
||||
import java.util.UUID
|
||||
|
||||
interface CreateUserUseCase {
|
||||
interface UserUseCase {
|
||||
fun create(command: CreateUserCommand): User
|
||||
|
||||
fun edit(
|
||||
id: UUID,
|
||||
command: EditUserCommand,
|
||||
): User
|
||||
|
||||
fun delete(id: UUID)
|
||||
|
||||
fun getById(id: UUID): User
|
||||
|
||||
fun getAll(): List<User>
|
||||
}
|
||||
|
||||
data class CreateUserCommand(
|
||||
@@ -12,26 +23,7 @@ data class CreateUserCommand(
|
||||
val email: String,
|
||||
)
|
||||
|
||||
interface EditUserUseCase {
|
||||
fun edit(
|
||||
id: UUID,
|
||||
command: EditUserCommand,
|
||||
): User
|
||||
}
|
||||
|
||||
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>
|
||||
}
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import com.project.movienight.domain.model.RecommendationEventType
|
||||
|
||||
interface BusinessMetricsPort {
|
||||
fun recordFilmCreated()
|
||||
|
||||
fun recordFilmEdited()
|
||||
|
||||
fun recordFilmDeleted()
|
||||
|
||||
fun recordFilmBlocked()
|
||||
|
||||
fun recordRecommendationRequest()
|
||||
|
||||
fun recordRecommendationWeightsUpdated(eventType: RecommendationEventType)
|
||||
|
||||
fun recordRatingSubmitted()
|
||||
|
||||
fun recordLibraryEvent()
|
||||
|
||||
fun recordJellyfinSync(summary: JellyfinSyncSummary)
|
||||
|
||||
fun recordJellyfinSyncFailure()
|
||||
|
||||
fun recordJellyfinUnmappedUser()
|
||||
|
||||
fun recordBackendWriteFailure()
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import java.util.UUID
|
||||
|
||||
interface FilmLibraryEntryRepositoryPort {
|
||||
fun save(entry: FilmLibraryEntry): FilmLibraryEntry
|
||||
|
||||
fun findById(id: UUID): FilmLibraryEntry?
|
||||
|
||||
fun findByUserId(userId: UUID): List<FilmLibraryEntry>
|
||||
|
||||
fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmLibraryEntry?
|
||||
|
||||
fun findAll(): List<FilmLibraryEntry>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
}
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import java.util.UUID
|
||||
|
||||
interface FilmLibraryRepositoryPort {
|
||||
fun save(filmLibrary: FilmLibrary): FilmLibrary
|
||||
|
||||
fun findById(id: UUID): FilmLibrary?
|
||||
|
||||
fun findByUserIdAndFilmId(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
): FilmLibrary?
|
||||
|
||||
fun findAll(): List<FilmLibrary>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
}
|
||||
@@ -10,8 +10,6 @@ interface FilmRepositoryPort {
|
||||
|
||||
fun findByJellyfinItemId(jellyfinItemId: String): Film?
|
||||
|
||||
fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film?
|
||||
|
||||
fun findAll(): List<Film>
|
||||
|
||||
fun findByTitle(title: String): Film?
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import com.project.movienight.domain.model.ContentType
|
||||
|
||||
interface JellyfinCatalogPort {
|
||||
fun fetchUsers(): List<JellyfinRemoteUser>
|
||||
|
||||
fun fetchLibraryItems(userId: String): List<JellyfinLibraryItemSnapshot>
|
||||
}
|
||||
|
||||
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,
|
||||
)
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.project.movienight.application.ports.output
|
||||
|
||||
import java.time.OffsetDateTime
|
||||
|
||||
interface JellyfinEventStorePort {
|
||||
fun save(event: JellyfinEventRecord): Boolean
|
||||
}
|
||||
|
||||
data class JellyfinEventRecord(
|
||||
val eventId: String,
|
||||
val serverId: String?,
|
||||
val eventType: String,
|
||||
val occurredAt: OffsetDateTime?,
|
||||
val jellyfinUserId: String?,
|
||||
val jellyfinItemId: String?,
|
||||
val payload: String?,
|
||||
)
|
||||
@@ -7,10 +7,24 @@ import java.util.UUID
|
||||
interface UserRepositoryPort {
|
||||
fun save(user: User): User
|
||||
|
||||
fun createOAuthUser(
|
||||
user: User,
|
||||
provider: AuthProvider,
|
||||
providerId: String,
|
||||
): User
|
||||
|
||||
fun linkOAuthAccount(
|
||||
userId: UUID,
|
||||
provider: AuthProvider,
|
||||
providerId: String,
|
||||
): User
|
||||
|
||||
fun findById(id: UUID): User?
|
||||
|
||||
fun findByEmail(email: String): User?
|
||||
|
||||
fun findByJellyfinUserId(jellyfinUserId: String): User?
|
||||
|
||||
fun findAll(): List<User>
|
||||
|
||||
fun deleteById(id: UUID)
|
||||
|
||||
@@ -1,46 +1,34 @@
|
||||
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.FilmLibraryUseCase
|
||||
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
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
|
||||
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.FilmLibrary
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class FilmLibraryService(
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) : CreateFilmLibraryUseCase,
|
||||
AddFilmToLibraryUseCase,
|
||||
MarkFilmViewedUseCase,
|
||||
RemoveFilmFromLibraryUseCase,
|
||||
GetFilmLibraryUseCase,
|
||||
ListFilmLibraryEntriesUseCase {
|
||||
override fun create(command: CreateFilmLibraryCommand): FilmLibrary {
|
||||
findByUserId(command.userId)?.let { return it }
|
||||
throw EntityNotFoundException(entity = "Film library", id = command.userId.toString())
|
||||
}
|
||||
private val businessMetricsService: BusinessMetricsPort,
|
||||
) : FilmLibraryUseCase {
|
||||
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibraryEntry {
|
||||
ensureFilmExists(command.filmId)
|
||||
|
||||
override fun addFilm(command: AddFilmToLibraryCommand): FilmLibrary {
|
||||
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
|
||||
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
|
||||
if (existingEntry != null) {
|
||||
val saved =
|
||||
filmLibraryRepository.save(
|
||||
filmLibraryEntryRepository.save(
|
||||
existingEntry.copy(
|
||||
isViewed = false,
|
||||
watchedAt = null,
|
||||
@@ -51,8 +39,8 @@ class FilmLibraryService(
|
||||
}
|
||||
|
||||
val saved =
|
||||
filmLibraryRepository.save(
|
||||
FilmLibrary(
|
||||
filmLibraryEntryRepository.save(
|
||||
FilmLibraryEntry(
|
||||
id = idGenerator.generateId(),
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
@@ -65,33 +53,35 @@ class FilmLibraryService(
|
||||
return saved
|
||||
}
|
||||
|
||||
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibrary {
|
||||
val existingLibrary =
|
||||
if (command.libraryId != null) {
|
||||
filmLibraryRepository.findById(command.libraryId)
|
||||
?: throw EntityNotFoundException(entity = "Film library", id = command.libraryId.toString())
|
||||
override fun removeFilm(command: RemoveFilmFromLibraryCommand): FilmLibraryEntry {
|
||||
val existingEntry =
|
||||
if (command.entryId != null) {
|
||||
filmLibraryEntryRepository.findById(command.entryId)
|
||||
?: throw EntityNotFoundException(entity = "Film library entry", id = command.entryId.toString())
|
||||
} else {
|
||||
findByUserAndFilmId(command.userId, command.filmId)
|
||||
?: throw EntityNotFoundException(entity = "Film library", id = command.filmId.toString())
|
||||
filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
|
||||
?: throw EntityNotFoundException(entity = "Film library entry", id = command.filmId.toString())
|
||||
}
|
||||
|
||||
if (existingLibrary.userId != command.userId || existingLibrary.filmId != command.filmId) {
|
||||
if (existingEntry.userId != command.userId || existingEntry.filmId != command.filmId) {
|
||||
throw DomainException("Film with id ${command.filmId} not found in user's library")
|
||||
}
|
||||
|
||||
filmLibraryRepository.deleteById(existingLibrary.id)
|
||||
filmLibraryEntryRepository.deleteById(existingEntry.id)
|
||||
businessMetricsService.recordLibraryEvent()
|
||||
return existingLibrary
|
||||
return existingEntry
|
||||
}
|
||||
|
||||
override fun markViewed(command: MarkFilmViewedCommand): FilmLibrary {
|
||||
val existingEntry = findByUserAndFilmId(command.userId, command.filmId)
|
||||
override fun markViewed(command: MarkFilmViewedCommand): FilmLibraryEntry {
|
||||
ensureFilmExists(command.filmId)
|
||||
|
||||
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(command.userId, command.filmId)
|
||||
val watchedAt = command.watchedAt ?: java.time.LocalDateTime.now()
|
||||
|
||||
val saved =
|
||||
if (existingEntry == null) {
|
||||
filmLibraryRepository.save(
|
||||
FilmLibrary(
|
||||
filmLibraryEntryRepository.save(
|
||||
FilmLibraryEntry(
|
||||
id = idGenerator.generateId(),
|
||||
userId = command.userId,
|
||||
filmId = command.filmId,
|
||||
@@ -101,7 +91,7 @@ class FilmLibraryService(
|
||||
),
|
||||
)
|
||||
} else {
|
||||
filmLibraryRepository.save(
|
||||
filmLibraryEntryRepository.save(
|
||||
existingEntry.copy(
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
@@ -112,17 +102,14 @@ class FilmLibraryService(
|
||||
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<FilmLibraryEntry> = filmLibraryEntryRepository.findByUserId(userId)
|
||||
|
||||
override fun list(userId: UUID): List<FilmLibrary> = filmLibraryRepository.findAll().filter { it.userId == userId }
|
||||
override fun listAvailableFilms(userId: UUID): List<Film> {
|
||||
val libraryFilmIds = list(userId).map { it.filmId }.toSet()
|
||||
return filmRepository.findAll().filter { it.id !in libraryFilmIds }
|
||||
}
|
||||
|
||||
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 }
|
||||
private fun ensureFilmExists(filmId: UUID) {
|
||||
filmRepository.findById(filmId) ?: throw EntityNotFoundException(entity = "Film", id = filmId.toString())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
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.FilmRatingUseCase
|
||||
import com.project.movienight.application.ports.input.RateFilmCommand
|
||||
import com.project.movienight.application.ports.input.RateFilmUseCase
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
@@ -19,9 +18,8 @@ class FilmRatingService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) : RateFilmUseCase,
|
||||
GetFilmRatingsUseCase {
|
||||
private val businessMetricsService: BusinessMetricsPort,
|
||||
) : FilmRatingUseCase {
|
||||
override fun rate(command: RateFilmCommand): FilmRating {
|
||||
if (command.score !in 1..10) {
|
||||
throw DomainException("Film rating score must be between 1 and 10")
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.CreateFilmCommand
|
||||
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.input.FilmUseCase
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
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 io.micrometer.core.annotation.Timed
|
||||
import org.slf4j.LoggerFactory
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
@@ -26,89 +20,81 @@ class FilmService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val filmConfig: FilmServiceProperties,
|
||||
private val meterRegistry: MeterRegistry,
|
||||
) : CreateFilmUseCase,
|
||||
EditFilmUseCase,
|
||||
DeleteFilmUseCase,
|
||||
GetFilmByIdUseCase,
|
||||
GetAllFilmsUseCase,
|
||||
SearchFilmByTitleUseCase {
|
||||
private val businessMetricsService: BusinessMetricsPort,
|
||||
) : FilmUseCase {
|
||||
private val log = LoggerFactory.getLogger(javaClass)
|
||||
|
||||
@Timed(
|
||||
value = "business_films_create_duration_seconds",
|
||||
description = "Film creation duration",
|
||||
)
|
||||
override fun create(command: CreateFilmCommand): Film {
|
||||
val sample = Timer.start(meterRegistry)
|
||||
log.debug(
|
||||
"Create film request received: title='{}', descriptionLength={}",
|
||||
command.title,
|
||||
command.description.length,
|
||||
)
|
||||
|
||||
try {
|
||||
log.debug(
|
||||
"Create film request received: title='{}', descriptionLength={}",
|
||||
command.title,
|
||||
command.description.length,
|
||||
if (filmConfig.isBlocked(command.title)) {
|
||||
log.debug("Create film blocked by title policy: title='{}'", command.title)
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
log.debug("Create film blocked by description policy")
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
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,
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
val saved = filmRepository.save(film)
|
||||
businessMetricsService.recordFilmCreated()
|
||||
return saved
|
||||
}
|
||||
|
||||
@Timed(
|
||||
value = "business_films_edit_duration_seconds",
|
||||
description = "Film edit duration",
|
||||
)
|
||||
override fun edit(
|
||||
id: UUID,
|
||||
command: EditFilmCommand,
|
||||
): Film {
|
||||
val sample = Timer.start(meterRegistry)
|
||||
log.debug("Edit film with id: {}", id)
|
||||
|
||||
try {
|
||||
log.debug("Edit film with id: {}", id)
|
||||
if (filmConfig.isBlocked(command.title)) {
|
||||
log.debug("Edit film blocked by title policy: title='{}'", command.title)
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
throw BlockedValueException(target = "Film", field = "title")
|
||||
}
|
||||
if (filmConfig.isBlocked(command.description)) {
|
||||
log.debug("Edit film blocked by description policy")
|
||||
businessMetricsService.recordFilmBlocked()
|
||||
throw BlockedValueException(target = "Film", field = "description")
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
val film =
|
||||
filmRepository.findById(id)
|
||||
?: throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
|
||||
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 =
|
||||
val saved =
|
||||
filmRepository.save(
|
||||
film.copy(
|
||||
title = command.title,
|
||||
description = command.description,
|
||||
@@ -122,37 +108,30 @@ class FilmService(
|
||||
externalUrl = command.externalUrl,
|
||||
jellyfinItemId = command.jellyfinItemId,
|
||||
jellyfinLibraryId = command.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
val saved = filmRepository.save(film)
|
||||
filmEditedCounter.increment()
|
||||
return saved
|
||||
} finally {
|
||||
sample.stop(editFilmTimer)
|
||||
}
|
||||
),
|
||||
)
|
||||
businessMetricsService.recordFilmEdited()
|
||||
return saved
|
||||
}
|
||||
|
||||
@Timed(
|
||||
value = "business_films_delete_duration_seconds",
|
||||
description = "Film deletion duration",
|
||||
)
|
||||
override fun delete(id: UUID) {
|
||||
val sample = Timer.start(meterRegistry)
|
||||
log.debug("Delete film with id: {}", id)
|
||||
|
||||
try {
|
||||
log.debug("Delete film with id: {}", id)
|
||||
val film = filmRepository.findById(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)
|
||||
if (film == null) {
|
||||
log.debug("Film not found for delete: id='{}'", id)
|
||||
throw EntityNotFoundException(entity = "Film", id = id.toString())
|
||||
}
|
||||
|
||||
filmRepository.deleteById(id)
|
||||
businessMetricsService.recordFilmDeleted()
|
||||
|
||||
log.info("Film deleted: id='{}'", id)
|
||||
}
|
||||
|
||||
override fun getById(id: UUID): Film =
|
||||
@@ -161,46 +140,4 @@ class FilmService(
|
||||
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)
|
||||
}
|
||||
|
||||
+30
-36
@@ -1,79 +1,73 @@
|
||||
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.FilmLibraryUseCase
|
||||
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinEventUseCase
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedCommand
|
||||
import com.project.movienight.application.ports.input.MarkFilmViewedUseCase
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.JellyfinEventRecord
|
||||
import com.project.movienight.application.ports.output.JellyfinEventStorePort
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.OffsetDateTime
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class JellyfinEventService(
|
||||
private val jellyfinEventRepository: JellyfinEventRepository,
|
||||
private val jellyfinEventStore: JellyfinEventStorePort,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val markFilmViewedUseCase: MarkFilmViewedUseCase,
|
||||
private val filmLibraryUseCase: FilmLibraryUseCase,
|
||||
private val objectMapper: ObjectMapper,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) {
|
||||
private val businessMetricsService: BusinessMetricsPort,
|
||||
) : JellyfinEventUseCase {
|
||||
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) }
|
||||
@Transactional
|
||||
override fun handle(command: HandleJellyfinEventCommand) {
|
||||
val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) }
|
||||
val inserted =
|
||||
jellyfinEventRepository.save(
|
||||
eventId = eventId,
|
||||
serverId = serverId,
|
||||
eventType = eventType,
|
||||
occurredAt = occurredAt,
|
||||
jellyfinUserId = jellyfinUserId,
|
||||
jellyfinItemId = itemId,
|
||||
payload = payloadJson,
|
||||
jellyfinEventStore.save(
|
||||
JellyfinEventRecord(
|
||||
eventId = command.eventId,
|
||||
serverId = command.serverId,
|
||||
eventType = command.eventType,
|
||||
occurredAt = command.occurredAt,
|
||||
jellyfinUserId = command.jellyfinUserId,
|
||||
jellyfinItemId = command.itemId,
|
||||
payload = payloadJson,
|
||||
),
|
||||
)
|
||||
if (inserted != 1) {
|
||||
if (!inserted) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (playbackEventTypes.contains(eventType)) {
|
||||
val localUser = userRepository.findAll().firstOrNull { it.jellyfinUserId == jellyfinUserId }
|
||||
if (playbackEventTypes.contains(command.eventType)) {
|
||||
val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId)
|
||||
if (localUser == null) {
|
||||
jellyfinEventRepository.delete(eventId)
|
||||
businessMetricsService.recordJellyfinUnmappedUser()
|
||||
return
|
||||
}
|
||||
|
||||
val film = filmRepository.findByJellyfinItemId(itemId)
|
||||
val film = filmRepository.findByJellyfinItemId(command.itemId)
|
||||
if (film == null) {
|
||||
jellyfinEventRepository.delete(eventId)
|
||||
businessMetricsService.recordBackendWriteFailure()
|
||||
return
|
||||
}
|
||||
|
||||
markFilmViewedUseCase.markViewed(
|
||||
filmLibraryUseCase.markViewed(
|
||||
MarkFilmViewedCommand(
|
||||
userId = localUser.id,
|
||||
filmId = film.id,
|
||||
watchedAt = occurredAt.toLocalDateTime(),
|
||||
watchedAt = command.occurredAt.toLocalDateTime(),
|
||||
),
|
||||
)
|
||||
businessMetricsService.recordLibraryEvent()
|
||||
}
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
|
||||
) {
|
||||
jellyfinEventRepository.delete(eventId)
|
||||
businessMetricsService.recordBackendWriteFailure()
|
||||
throw ex
|
||||
}
|
||||
|
||||
+140
-34
@@ -1,18 +1,19 @@
|
||||
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.input.JellyfinPluginSyncCommand
|
||||
import com.project.movienight.application.ports.input.JellyfinSyncUseCase
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.JellyfinCatalogPort
|
||||
import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapshot
|
||||
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.FilmLibraryEntry
|
||||
import com.project.movienight.domain.model.JellyfinSyncState
|
||||
import com.project.movienight.domain.model.JellyfinSyncSummary
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
@@ -20,18 +21,19 @@ import org.springframework.stereotype.Service
|
||||
import java.time.Duration
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Service
|
||||
class JellyfinSyncService(
|
||||
private val properties: JellyfinIntegrationProperties,
|
||||
private val jellyfinApiClient: JellyfinApiClient,
|
||||
private val jellyfinCatalog: JellyfinCatalogPort,
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
|
||||
private val syncStateRepository: JellyfinSyncStateRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val businessMetricsService: BusinessMetricsService,
|
||||
) {
|
||||
private val businessMetricsService: BusinessMetricsPort,
|
||||
) : JellyfinSyncUseCase {
|
||||
@Scheduled(fixedDelayString = "\${integrations.jellyfin.sync-interval-ms:1800000}")
|
||||
fun scheduledSync() {
|
||||
if (properties.enabled) {
|
||||
@@ -39,13 +41,99 @@ class JellyfinSyncService(
|
||||
}
|
||||
}
|
||||
|
||||
fun syncNow(): JellyfinSyncSummary {
|
||||
override fun syncNow(): JellyfinSyncSummary {
|
||||
if (!properties.enabled || properties.baseUrl.isBlank() || properties.apiKey.isBlank()) {
|
||||
return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0)
|
||||
}
|
||||
|
||||
return try {
|
||||
runSync()
|
||||
} catch (
|
||||
@Suppress("TooGenericExceptionCaught") ex: RuntimeException,
|
||||
) {
|
||||
businessMetricsService.recordJellyfinSyncFailure()
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
|
||||
|
||||
override fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary {
|
||||
val startedAt = Instant.now()
|
||||
val remoteUsers = jellyfinApiClient.fetchUsers()
|
||||
val localUsersByJellyfinId =
|
||||
userRepository
|
||||
.findAll()
|
||||
.mapNotNull { user ->
|
||||
user.jellyfinUserId?.let { it to user }
|
||||
}.toMap()
|
||||
val syncedItemCountsByUser = mutableMapOf<UUID, Int>()
|
||||
var skippedUsers = 0
|
||||
|
||||
command.items.forEach { item ->
|
||||
val savedFilm =
|
||||
upsertFilm(
|
||||
JellyfinLibraryItemSnapshot(
|
||||
jellyfinItemId = item.jellyfinItemId,
|
||||
title = item.title,
|
||||
description = item.description.orEmpty(),
|
||||
contentType = ContentType.FILM,
|
||||
releaseYear = item.year,
|
||||
genres = item.genres,
|
||||
cast = emptyList(),
|
||||
directors = emptyList(),
|
||||
platformRating = null,
|
||||
imdbRating = null,
|
||||
externalUrl = item.imdbId?.let { imdbUrl(it) },
|
||||
jellyfinLibraryId = null,
|
||||
isPlayed = false,
|
||||
),
|
||||
)
|
||||
|
||||
item.userStates.forEach { state ->
|
||||
val localUser = localUsersByJellyfinId[state.jellyfinUserId]
|
||||
if (localUser == null) {
|
||||
skippedUsers += 1
|
||||
return@forEach
|
||||
}
|
||||
|
||||
syncedItemCountsByUser.merge(localUser.id, 1, Int::plus)
|
||||
if (state.isViewed) {
|
||||
markFilmViewed(
|
||||
userId = localUser.id,
|
||||
filmId = savedFilm.id,
|
||||
watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val now = LocalDateTime.now()
|
||||
syncedItemCountsByUser.forEach { (userId, syncedItemCount) ->
|
||||
syncStateRepository.save(
|
||||
JellyfinSyncState(
|
||||
userId = userId,
|
||||
lastSyncedAt = now,
|
||||
lastSuccessfulSyncAt = now,
|
||||
lastError = null,
|
||||
syncedItemCount = syncedItemCount,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val summary =
|
||||
JellyfinSyncSummary(
|
||||
syncedUsers = syncedItemCountsByUser.size,
|
||||
skippedUsers = skippedUsers,
|
||||
syncedItems = command.items.size,
|
||||
durationMs = Duration.between(startedAt, Instant.now()).toMillis(),
|
||||
)
|
||||
businessMetricsService.recordJellyfinSync(summary)
|
||||
return summary
|
||||
}
|
||||
|
||||
private fun runSync(): JellyfinSyncSummary {
|
||||
val startedAt = Instant.now()
|
||||
val remoteUsers = jellyfinCatalog.fetchUsers()
|
||||
val localUsersByJellyfinId =
|
||||
userRepository
|
||||
.findAll()
|
||||
@@ -64,7 +152,7 @@ class JellyfinSyncService(
|
||||
return@forEach
|
||||
}
|
||||
|
||||
val items = jellyfinApiClient.fetchLibraryItems(remoteUser.id)
|
||||
val items = jellyfinCatalog.fetchLibraryItems(remoteUser.id)
|
||||
items.forEach { item ->
|
||||
syncItem(localUser.id, item)
|
||||
syncedItems += 1
|
||||
@@ -94,12 +182,22 @@ class JellyfinSyncService(
|
||||
return summary
|
||||
}
|
||||
|
||||
fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll()
|
||||
|
||||
private fun syncItem(
|
||||
userId: java.util.UUID,
|
||||
userId: UUID,
|
||||
item: JellyfinLibraryItemSnapshot,
|
||||
) {
|
||||
val savedFilm = upsertFilm(item)
|
||||
|
||||
if (item.isPlayed) {
|
||||
markFilmViewed(
|
||||
userId = userId,
|
||||
filmId = savedFilm.id,
|
||||
watchedAt = LocalDateTime.now(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film {
|
||||
val film =
|
||||
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy(
|
||||
title = item.title,
|
||||
@@ -130,24 +228,32 @@ class JellyfinSyncService(
|
||||
jellyfinLibraryId = item.jellyfinLibraryId,
|
||||
)
|
||||
|
||||
val savedFilm = filmRepository.save(film)
|
||||
return 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,
|
||||
),
|
||||
)
|
||||
}
|
||||
private fun markFilmViewed(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
watchedAt: LocalDateTime,
|
||||
) {
|
||||
val existingEntry = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
|
||||
filmLibraryEntryRepository.save(
|
||||
existingEntry?.copy(
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
) ?: FilmLibraryEntry(
|
||||
id = idGenerator.generateId(),
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
comment = null,
|
||||
isViewed = true,
|
||||
watchedAt = watchedAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun imdbUrl(imdbId: String): String {
|
||||
val normalized = imdbId.trim().lowercase().let { if (it.startsWith("tt")) it else "tt$it" }
|
||||
return "https://www.imdb.com/title/$normalized/"
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -3,7 +3,7 @@ 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.FilmLibraryEntryRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
@@ -11,7 +11,7 @@ import com.project.movienight.application.ports.output.UserPreferencesRepository
|
||||
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.FilmLibraryEntry
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import com.project.movienight.domain.model.UserRecommendationWeights
|
||||
@@ -25,7 +25,7 @@ class RecommendationOnboardingService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||
private val filmRatingRepository: FilmRatingRepositoryPort,
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
|
||||
private val userRecommendationWeightsRepository: UserRecommendationWeightsRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
) : CompleteRecommendationOnboardingUseCase {
|
||||
@@ -125,12 +125,12 @@ class RecommendationOnboardingService(
|
||||
userId: UUID,
|
||||
filmId: UUID,
|
||||
isViewed: Boolean,
|
||||
): FilmLibrary {
|
||||
): FilmLibraryEntry {
|
||||
val watchedAt = LocalDateTime.now().takeIf { isViewed }
|
||||
val existing = filmLibraryRepository.findByUserIdAndFilmId(userId, filmId)
|
||||
return filmLibraryRepository.save(
|
||||
val existing = filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId)
|
||||
return filmLibraryEntryRepository.save(
|
||||
existing?.copy(isViewed = isViewed, watchedAt = watchedAt)
|
||||
?: FilmLibrary(
|
||||
?: FilmLibraryEntry(
|
||||
id = idGenerator.generateId(),
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
|
||||
+7
-7
@@ -1,13 +1,13 @@
|
||||
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.BusinessMetricsPort
|
||||
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRatingRepositoryPort
|
||||
import com.project.movienight.application.ports.output.FilmRepositoryPort
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
@@ -17,7 +17,7 @@ import com.project.movienight.application.ports.output.UserRecommendationWeights
|
||||
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.FilmLibraryEntry
|
||||
import com.project.movienight.domain.model.FilmRating
|
||||
import com.project.movienight.domain.model.RecommendationEvent
|
||||
import com.project.movienight.domain.model.RecommendationEventType
|
||||
@@ -34,14 +34,14 @@ import kotlin.math.sqrt
|
||||
@Service
|
||||
class RecommendationService(
|
||||
private val filmRepository: FilmRepositoryPort,
|
||||
private val filmLibraryRepository: FilmLibraryRepositoryPort,
|
||||
private val filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort,
|
||||
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,
|
||||
private val businessMetricsService: BusinessMetricsPort,
|
||||
) : GetRecommendationsUseCase,
|
||||
AcceptRecommendationUseCase,
|
||||
RejectRecommendationUseCase {
|
||||
@@ -54,7 +54,7 @@ class RecommendationService(
|
||||
|
||||
val preferences = userPreferencesRepository.findByUserId(query.userId)
|
||||
val ratings = filmRatingRepository.findByUserId(query.userId)
|
||||
val libraryEntries = filmLibraryRepository.findAll().filter { it.userId == query.userId }
|
||||
val libraryEntries = filmLibraryEntryRepository.findByUserId(query.userId)
|
||||
val libraryFilmIds = libraryEntries.map { it.filmId }.toSet()
|
||||
val watchedFilmIds = libraryEntries.filter { it.isViewed }.map { it.filmId }.toSet()
|
||||
val films = filmRepository.findAll()
|
||||
@@ -278,7 +278,7 @@ class RecommendationService(
|
||||
private fun buildUserProfile(
|
||||
preferences: UserPreferences?,
|
||||
ratings: List<FilmRating>,
|
||||
libraryEntries: List<FilmLibrary>,
|
||||
libraryEntries: List<FilmLibraryEntry>,
|
||||
filmsById: Map<UUID, Film>,
|
||||
weights: UserRecommendationWeights,
|
||||
): SparseVector {
|
||||
|
||||
+2
-4
@@ -1,8 +1,7 @@
|
||||
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.input.UserPreferencesUseCase
|
||||
import com.project.movienight.application.ports.output.UserPreferencesRepositoryPort
|
||||
import com.project.movienight.domain.model.UserPreferences
|
||||
import org.springframework.stereotype.Service
|
||||
@@ -10,8 +9,7 @@ import org.springframework.stereotype.Service
|
||||
@Service
|
||||
class UserPreferencesService(
|
||||
private val userPreferencesRepository: UserPreferencesRepositoryPort,
|
||||
) : UpsertUserPreferencesUseCase,
|
||||
GetUserPreferencesUseCase {
|
||||
) : UserPreferencesUseCase {
|
||||
override fun upsert(command: UpsertUserPreferencesCommand): UserPreferences =
|
||||
userPreferencesRepository.save(
|
||||
UserPreferences(
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.CreateUserCommand
|
||||
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.input.UserUseCase
|
||||
import com.project.movienight.application.ports.output.IdGenerator
|
||||
import com.project.movienight.application.ports.output.UserRepositoryPort
|
||||
import com.project.movienight.config.UserServiceProperties
|
||||
@@ -21,11 +17,7 @@ class UserService(
|
||||
private val userRepository: UserRepositoryPort,
|
||||
private val idGenerator: IdGenerator,
|
||||
private val userConfig: UserServiceProperties,
|
||||
) : CreateUserUseCase,
|
||||
EditUserUseCase,
|
||||
DeleteUserUseCase,
|
||||
GetUserByIdUseCase,
|
||||
GetAllUsersUseCase {
|
||||
) : UserUseCase {
|
||||
override fun create(command: CreateUserCommand): User {
|
||||
if (userConfig.isBlocked(command.name)) {
|
||||
throw BlockedValueException(target = "User", field = "name")
|
||||
@@ -36,7 +28,6 @@ class UserService(
|
||||
id = idGenerator.generateId(),
|
||||
name = command.name,
|
||||
email = command.email,
|
||||
library = null,
|
||||
jellyfinUserId = null,
|
||||
)
|
||||
return userRepository.save(user)
|
||||
|
||||
@@ -6,7 +6,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties
|
||||
data class JellyfinIntegrationProperties(
|
||||
val enabled: Boolean = false,
|
||||
val baseUrl: String = "",
|
||||
val webUrl: String = "",
|
||||
val webUrl: String = baseUrl,
|
||||
val apiKey: String = "",
|
||||
val syncIntervalMs: Long = 1_800_000,
|
||||
val requestTimeoutMs: Long = 20_000,
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.project.movienight.config
|
||||
|
||||
import io.micrometer.core.aop.TimedAspect
|
||||
import io.micrometer.core.instrument.MeterRegistry
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
|
||||
@Configuration
|
||||
class MetricsConfiguration {
|
||||
@Bean
|
||||
fun timedAspect(meterRegistry: MeterRegistry): TimedAspect = TimedAspect(meterRegistry)
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@ package com.project.movienight.domain.model
|
||||
import java.time.LocalDateTime
|
||||
import java.util.UUID
|
||||
|
||||
data class FilmLibrary(
|
||||
data class FilmLibraryEntry(
|
||||
val id: UUID,
|
||||
val userId: UUID,
|
||||
val filmId: UUID,
|
||||
@@ -6,7 +6,6 @@ data class User(
|
||||
val id: UUID,
|
||||
val name: String,
|
||||
val email: String,
|
||||
val library: FilmLibrary?,
|
||||
val preferences: UserPreferences? = null,
|
||||
val jellyfinUserId: String? = null,
|
||||
)
|
||||
|
||||
@@ -101,6 +101,7 @@ integrations:
|
||||
base-url: ${JELLYFIN_BASE_URL:}
|
||||
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
|
||||
api-key: ${JELLYFIN_API_KEY:}
|
||||
plugin-token: ${JELLYFIN_PLUGIN_TOKEN:}
|
||||
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
|
||||
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
|
||||
|
||||
|
||||
@@ -6,12 +6,26 @@ erDiagram
|
||||
UUID id PK
|
||||
VARCHAR name
|
||||
VARCHAR email
|
||||
VARCHAR provider
|
||||
VARCHAR provider_id
|
||||
VARCHAR jellyfin_user_id
|
||||
TIMESTAMP created_at
|
||||
}
|
||||
|
||||
films {
|
||||
UUID id PK
|
||||
VARCHAR title
|
||||
TEXT description
|
||||
VARCHAR content_type
|
||||
INT release_year
|
||||
TEXT genres
|
||||
TEXT cast_members
|
||||
TEXT directors
|
||||
DOUBLE imdb_rating
|
||||
DOUBLE platform_rating
|
||||
TEXT external_url
|
||||
VARCHAR jellyfin_item_id
|
||||
VARCHAR jellyfin_library_id
|
||||
}
|
||||
|
||||
favorites {
|
||||
@@ -20,8 +34,53 @@ erDiagram
|
||||
UUID film_id FK
|
||||
VARCHAR comment
|
||||
BOOLEAN is_viewed
|
||||
TIMESTAMP watched_at
|
||||
}
|
||||
|
||||
user_preferences {
|
||||
UUID user_id PK,FK
|
||||
TEXT weighted_genres
|
||||
TEXT plot_types
|
||||
TEXT eras
|
||||
TEXT cast_and_directors
|
||||
TEXT moods
|
||||
TEXT content_types
|
||||
}
|
||||
|
||||
film_ratings {
|
||||
UUID id PK
|
||||
UUID user_id FK
|
||||
UUID film_id FK
|
||||
INT score
|
||||
VARCHAR note
|
||||
TIMESTAMP created_at
|
||||
TIMESTAMP updated_at
|
||||
}
|
||||
|
||||
jellyfin_events {
|
||||
VARCHAR event_id PK
|
||||
VARCHAR server_id
|
||||
VARCHAR event_type
|
||||
TIMESTAMP occurred_at
|
||||
VARCHAR jellyfin_user_id
|
||||
VARCHAR jellyfin_item_id
|
||||
JSON payload
|
||||
TIMESTAMP created_at
|
||||
}
|
||||
|
||||
jellyfin_sync_state {
|
||||
UUID user_id PK,FK
|
||||
TIMESTAMP last_synced_at
|
||||
TIMESTAMP last_successful_sync_at
|
||||
TEXT last_error
|
||||
INT synced_item_count
|
||||
TIMESTAMP updated_at
|
||||
}
|
||||
|
||||
users ||--o{ favorites : has
|
||||
films ||--o{ favorites : linked
|
||||
users ||--o{ film_ratings : rates
|
||||
films ||--o{ film_ratings : rated
|
||||
users ||--|| user_preferences : configures
|
||||
users ||--|| jellyfin_sync_state : syncs
|
||||
```
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
DROP TABLE IF EXISTS public.ratings;
|
||||
|
||||
ALTER TABLE public.users
|
||||
DROP COLUMN IF EXISTS jellyfin_id;
|
||||
|
||||
ALTER TABLE public.films
|
||||
DROP COLUMN IF EXISTS jellyfin_id;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_jellyfin_user_id ON public.users(jellyfin_user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_films_jellyfin_item_id ON public.films(jellyfin_item_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_films_jellyfin_library_id ON public.films(jellyfin_library_id);
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.project.movienight
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import kotlin.test.assertNotNull
|
||||
|
||||
class ClassLoaderTest {
|
||||
@Test
|
||||
fun `can load OAuth2ClientProperties class`() {
|
||||
val clazz =
|
||||
Class.forName(
|
||||
"org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties",
|
||||
)
|
||||
assertNotNull(clazz)
|
||||
println("Successfully loaded: ${clazz.name}")
|
||||
println("ClassLoader: ${clazz.classLoader}")
|
||||
}
|
||||
}
|
||||
-4
@@ -26,7 +26,6 @@ class UserEntityMappingTest {
|
||||
assertEquals(entity.id, user.id)
|
||||
assertEquals(entity.name, user.name)
|
||||
assertEquals(entity.email, user.email)
|
||||
assertNull(user.library)
|
||||
assertNull(user.jellyfinUserId)
|
||||
}
|
||||
|
||||
@@ -37,7 +36,6 @@ class UserEntityMappingTest {
|
||||
id = UUID.randomUUID(),
|
||||
name = "Jane",
|
||||
email = "jane@mail.com",
|
||||
library = null,
|
||||
)
|
||||
|
||||
val entity = user.toEntity(AuthProvider.YANDEX, "yandex456")
|
||||
@@ -56,7 +54,6 @@ class UserEntityMappingTest {
|
||||
id = UUID.randomUUID(),
|
||||
name = "Bob",
|
||||
email = "bob@mail.com",
|
||||
library = null,
|
||||
)
|
||||
|
||||
val entity = user.toEntity()
|
||||
@@ -72,7 +69,6 @@ class UserEntityMappingTest {
|
||||
id = UUID.randomUUID(),
|
||||
name = "Alice",
|
||||
email = "alice@email.com",
|
||||
library = null,
|
||||
)
|
||||
|
||||
val mapped = original.toEntity().toDomain()
|
||||
|
||||
+39
-39
@@ -1,7 +1,7 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.FilmLibrary
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -19,9 +19,9 @@ import java.util.UUID
|
||||
|
||||
@SpringBootTest
|
||||
@ActiveProfiles("test")
|
||||
class FilmLibraryRepositoryIntegrationTest {
|
||||
class FilmLibraryEntryRepositoryIntegrationTest {
|
||||
@Autowired
|
||||
private lateinit var filmLibraryRepository: FilmLibraryRepository
|
||||
private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepository
|
||||
|
||||
@Autowired
|
||||
private lateinit var userRepository: UserRepository
|
||||
@@ -62,7 +62,7 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
@Test
|
||||
fun `should save new film library entry and return saved entry`() {
|
||||
val entry =
|
||||
FilmLibrary(
|
||||
FilmLibraryEntry(
|
||||
id = UUID.randomUUID(),
|
||||
userId = testUser.id,
|
||||
filmId = testFilm.id,
|
||||
@@ -70,7 +70,7 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
isViewed = false,
|
||||
)
|
||||
|
||||
val savedEntry = filmLibraryRepository.save(entry)
|
||||
val savedEntry = filmLibraryEntryRepository.save(entry)
|
||||
|
||||
assertNotNull(savedEntry)
|
||||
assertEquals(entry.id, savedEntry.id)
|
||||
@@ -83,17 +83,17 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
@Test
|
||||
fun `should update existing film library entry`() {
|
||||
val entryId = UUID.randomUUID()
|
||||
val originalEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false)
|
||||
filmLibraryRepository.save(originalEntry)
|
||||
val originalEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Хочу посмотреть", false)
|
||||
filmLibraryEntryRepository.save(originalEntry)
|
||||
|
||||
val updatedEntry = FilmLibrary(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true)
|
||||
val result = filmLibraryRepository.save(updatedEntry)
|
||||
val updatedEntry = FilmLibraryEntry(entryId, testUser.id, testFilm.id, "Уже посмотрел, потрясающе!", true)
|
||||
val result = filmLibraryEntryRepository.save(updatedEntry)
|
||||
|
||||
assertEquals(entryId, result.id)
|
||||
assertEquals("Уже посмотрел, потрясающе!", result.comment)
|
||||
assertTrue(result.isViewed)
|
||||
|
||||
val foundEntry = filmLibraryRepository.findById(entryId)
|
||||
val foundEntry = filmLibraryEntryRepository.findById(entryId)
|
||||
assertNotNull(foundEntry)
|
||||
assertEquals("Уже посмотрел, потрясающе!", foundEntry?.comment)
|
||||
assertTrue(foundEntry?.isViewed ?: false)
|
||||
@@ -101,10 +101,10 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `should find film library entry by id`() {
|
||||
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false)
|
||||
filmLibraryRepository.save(entry)
|
||||
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Обязательно посмотреть", false)
|
||||
filmLibraryEntryRepository.save(entry)
|
||||
|
||||
val foundEntry = filmLibraryRepository.findById(entry.id)
|
||||
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
|
||||
|
||||
assertNotNull(foundEntry)
|
||||
assertEquals(entry.id, foundEntry?.id)
|
||||
@@ -118,22 +118,22 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
fun `should return null when film library entry not found by id`() {
|
||||
val nonExistentId = UUID.randomUUID()
|
||||
|
||||
val foundEntry = filmLibraryRepository.findById(nonExistentId)
|
||||
val foundEntry = filmLibraryEntryRepository.findById(nonExistentId)
|
||||
|
||||
assertNull(foundEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should find all film library entries`() {
|
||||
val entry1 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false)
|
||||
val entry2 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true)
|
||||
val entry3 = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
|
||||
val entry1 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 1", false)
|
||||
val entry2 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Комментарий 2", true)
|
||||
val entry3 = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
|
||||
|
||||
filmLibraryRepository.save(entry1)
|
||||
filmLibraryRepository.save(entry2)
|
||||
filmLibraryRepository.save(entry3)
|
||||
filmLibraryEntryRepository.save(entry1)
|
||||
filmLibraryEntryRepository.save(entry2)
|
||||
filmLibraryEntryRepository.save(entry3)
|
||||
|
||||
val allEntries = filmLibraryRepository.findAll()
|
||||
val allEntries = filmLibraryEntryRepository.findAll()
|
||||
|
||||
assertEquals(3, allEntries.size)
|
||||
assertTrue(allEntries.any { it.id == entry1.id })
|
||||
@@ -143,19 +143,19 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `should return empty list when no film library entries exist`() {
|
||||
val allEntries = filmLibraryRepository.findAll()
|
||||
val allEntries = filmLibraryEntryRepository.findAll()
|
||||
|
||||
assertTrue(allEntries.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should delete film library entry by id`() {
|
||||
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false)
|
||||
filmLibraryRepository.save(entry)
|
||||
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Для удаления", false)
|
||||
filmLibraryEntryRepository.save(entry)
|
||||
|
||||
filmLibraryRepository.deleteById(entry.id)
|
||||
filmLibraryEntryRepository.deleteById(entry.id)
|
||||
|
||||
val foundEntry = filmLibraryRepository.findById(entry.id)
|
||||
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
|
||||
assertNull(foundEntry)
|
||||
}
|
||||
|
||||
@@ -163,14 +163,14 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
fun `should not throw exception when deleting non-existent entry`() {
|
||||
val nonExistentId = UUID.randomUUID()
|
||||
|
||||
filmLibraryRepository.deleteById(nonExistentId)
|
||||
filmLibraryEntryRepository.deleteById(nonExistentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should save entry with null comment`() {
|
||||
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
|
||||
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, null, false)
|
||||
|
||||
val savedEntry = filmLibraryRepository.save(entry)
|
||||
val savedEntry = filmLibraryEntryRepository.save(entry)
|
||||
|
||||
assertNotNull(savedEntry)
|
||||
assertNull(savedEntry.comment)
|
||||
@@ -178,9 +178,9 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `should save entry with isViewed true`() {
|
||||
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true)
|
||||
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Посмотрел", true)
|
||||
|
||||
val savedEntry = filmLibraryRepository.save(entry)
|
||||
val savedEntry = filmLibraryEntryRepository.save(entry)
|
||||
|
||||
assertNotNull(savedEntry)
|
||||
assertTrue(savedEntry.isViewed)
|
||||
@@ -188,9 +188,9 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `should save entry with isViewed false`() {
|
||||
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false)
|
||||
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Еще не смотрел", false)
|
||||
|
||||
val savedEntry = filmLibraryRepository.save(entry)
|
||||
val savedEntry = filmLibraryEntryRepository.save(entry)
|
||||
|
||||
assertNotNull(savedEntry)
|
||||
assertFalse(savedEntry.isViewed)
|
||||
@@ -198,23 +198,23 @@ class FilmLibraryRepositoryIntegrationTest {
|
||||
|
||||
@Test
|
||||
fun `should cascade delete entries when user is deleted`() {
|
||||
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false)
|
||||
filmLibraryRepository.save(entry)
|
||||
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Любимый фильм пользователя", false)
|
||||
filmLibraryEntryRepository.save(entry)
|
||||
|
||||
userRepository.deleteById(testUser.id)
|
||||
|
||||
val foundEntry = filmLibraryRepository.findById(entry.id)
|
||||
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
|
||||
assertNull(foundEntry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should cascade delete entries when film is deleted`() {
|
||||
val entry = FilmLibrary(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false)
|
||||
filmLibraryRepository.save(entry)
|
||||
val entry = FilmLibraryEntry(UUID.randomUUID(), testUser.id, testFilm.id, "Запись о фильме", false)
|
||||
filmLibraryEntryRepository.save(entry)
|
||||
|
||||
filmRepository.deleteById(testFilm.id)
|
||||
|
||||
val foundEntry = filmLibraryRepository.findById(entry.id)
|
||||
val foundEntry = filmLibraryEntryRepository.findById(entry.id)
|
||||
assertNull(foundEntry)
|
||||
}
|
||||
}
|
||||
+45
-8
@@ -1,5 +1,6 @@
|
||||
package com.project.movienight.adapters.persistence.jdbc
|
||||
|
||||
import com.project.movienight.domain.model.AuthProvider
|
||||
import com.project.movienight.domain.model.User
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
@@ -46,7 +47,6 @@ class UserRepositoryIntegrationTest {
|
||||
id = UUID.randomUUID(),
|
||||
name = "John Doe",
|
||||
email = "john@example.com",
|
||||
library = null,
|
||||
)
|
||||
|
||||
val savedUser = userRepository.save(user)
|
||||
@@ -61,10 +61,10 @@ class UserRepositoryIntegrationTest {
|
||||
fun `should update existing user`() {
|
||||
// given
|
||||
val userId = UUID.randomUUID()
|
||||
val originalUser = User(userId, "John Doe", "john@example.com", null)
|
||||
val originalUser = User(userId, "John Doe", "john@example.com")
|
||||
userRepository.save(originalUser)
|
||||
|
||||
val updatedUser = User(userId, "Jane Doe", "jane@example.com", null)
|
||||
val updatedUser = User(userId, "Jane Doe", "jane@example.com")
|
||||
val result = userRepository.save(updatedUser)
|
||||
|
||||
assertEquals(userId, result.id)
|
||||
@@ -80,7 +80,7 @@ class UserRepositoryIntegrationTest {
|
||||
@Test
|
||||
fun `should find user by id`() {
|
||||
// given
|
||||
val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
|
||||
val user = User(UUID.randomUUID(), "John Doe", "john@example.com")
|
||||
userRepository.save(user)
|
||||
|
||||
// when
|
||||
@@ -108,9 +108,9 @@ class UserRepositoryIntegrationTest {
|
||||
@Test
|
||||
fun `should find all users`() {
|
||||
// given
|
||||
val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
|
||||
val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com", null)
|
||||
val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com", null)
|
||||
val user1 = User(UUID.randomUUID(), "John Doe", "john@example.com")
|
||||
val user2 = User(UUID.randomUUID(), "Jane Smith", "jane@example.com")
|
||||
val user3 = User(UUID.randomUUID(), "Bob Johnson", "bob@example.com")
|
||||
|
||||
userRepository.save(user1)
|
||||
userRepository.save(user2)
|
||||
@@ -138,7 +138,7 @@ class UserRepositoryIntegrationTest {
|
||||
@Test
|
||||
fun `should delete user by id`() {
|
||||
// given
|
||||
val user = User(UUID.randomUUID(), "John Doe", "john@example.com", null)
|
||||
val user = User(UUID.randomUUID(), "John Doe", "john@example.com")
|
||||
userRepository.save(user)
|
||||
|
||||
// when
|
||||
@@ -157,4 +157,41 @@ class UserRepositoryIntegrationTest {
|
||||
// when & then (no exception should be thrown)
|
||||
userRepository.deleteById(nonExistentId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should create OAuth user with provider identity`() {
|
||||
val user = User(UUID.randomUUID(), "OAuth User", "oauth@example.com")
|
||||
|
||||
val savedUser = userRepository.createOAuthUser(user, AuthProvider.GOOGLE, "google-123")
|
||||
|
||||
assertEquals(user.id, savedUser.id)
|
||||
assertEquals(user.email, savedUser.email)
|
||||
|
||||
val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.GOOGLE, "google-123")
|
||||
assertNotNull(foundByProvider)
|
||||
assertEquals(user.id, foundByProvider?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should link OAuth account to existing user`() {
|
||||
val user = userRepository.save(User(UUID.randomUUID(), "Link User", "link@example.com"))
|
||||
|
||||
val linkedUser = userRepository.linkOAuthAccount(user.id, AuthProvider.YANDEX, "yandex-456")
|
||||
|
||||
assertEquals(user.id, linkedUser.id)
|
||||
val foundByProvider = userRepository.findByProviderAndProviderId(AuthProvider.YANDEX, "yandex-456")
|
||||
assertNotNull(foundByProvider)
|
||||
assertEquals(user.id, foundByProvider?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `find by email should include jellyfin user id`() {
|
||||
val user = userRepository.save(User(UUID.randomUUID(), "Jellyfin User", "jellyfin@example.com"))
|
||||
jdbcTemplate.update("UPDATE users SET jellyfin_user_id = ? WHERE id = ?", "jellyfin-789", user.id)
|
||||
|
||||
val foundUser = userRepository.findByEmail(user.email)
|
||||
|
||||
assertNotNull(foundUser)
|
||||
assertEquals("jellyfin-789", foundUser?.jellyfinUserId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
package com.project.movienight.adapters.web
|
||||
|
||||
import com.project.movienight.application.ports.input.CreateFilmUseCase
|
||||
import com.project.movienight.application.ports.input.DeleteFilmUseCase
|
||||
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.input.FilmUseCase
|
||||
import com.project.movienight.domain.model.Film
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
@@ -19,20 +14,15 @@ import java.util.UUID
|
||||
|
||||
class FilmControllerSearchTest {
|
||||
private lateinit var mockMvc: MockMvc
|
||||
private lateinit var searchFilmByTitleUseCase: SearchFilmByTitleUseCase
|
||||
private lateinit var filmUseCase: FilmUseCase
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
searchFilmByTitleUseCase = mockk()
|
||||
filmUseCase = mockk()
|
||||
|
||||
val controller =
|
||||
FilmController(
|
||||
createFilmUseCase = mockk<CreateFilmUseCase>(),
|
||||
editFilmUseCase = mockk<EditFilmUseCase>(),
|
||||
deleteFilmUseCase = mockk<DeleteFilmUseCase>(),
|
||||
getFilmByIdUseCase = mockk<GetFilmByIdUseCase>(),
|
||||
getAllFilmsUseCase = mockk<GetAllFilmsUseCase>(),
|
||||
searchFilmByTitleUseCase = searchFilmByTitleUseCase,
|
||||
filmUseCase = filmUseCase,
|
||||
)
|
||||
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build()
|
||||
@@ -43,7 +33,7 @@ class FilmControllerSearchTest {
|
||||
val title = "Inception"
|
||||
val film = Film(id = UUID.randomUUID(), title = title, description = "A dream heist")
|
||||
|
||||
every { searchFilmByTitleUseCase.searchByTitle(title) } returns film
|
||||
every { filmUseCase.searchByTitle(title) } returns film
|
||||
|
||||
mockMvc
|
||||
.get("/api/films/search") {
|
||||
@@ -55,14 +45,14 @@ class FilmControllerSearchTest {
|
||||
jsonPath("$.description") { value("A dream heist") }
|
||||
}
|
||||
|
||||
verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) }
|
||||
verify(exactly = 1) { filmUseCase.searchByTitle(title) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search returns 404 when title is not found`() {
|
||||
val title = "Unknown Title"
|
||||
|
||||
every { searchFilmByTitleUseCase.searchByTitle(title) } returns null
|
||||
every { filmUseCase.searchByTitle(title) } returns null
|
||||
|
||||
mockMvc
|
||||
.get("/api/films/search") {
|
||||
@@ -72,6 +62,6 @@ class FilmControllerSearchTest {
|
||||
content { string("") }
|
||||
}
|
||||
|
||||
verify(exactly = 1) { searchFilmByTitleUseCase.searchByTitle(title) }
|
||||
verify(exactly = 1) { filmUseCase.searchByTitle(title) }
|
||||
}
|
||||
}
|
||||
|
||||
+75
-174
@@ -1,15 +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.CreateFilmLibraryCommand
|
||||
import com.project.movienight.application.ports.input.GetFilmLibraryQuery
|
||||
import com.project.movienight.application.ports.input.RemoveFilmFromLibraryCommand
|
||||
import com.project.movienight.application.ports.output.FilmLibraryRepositoryPort
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort
|
||||
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.FilmLibrary
|
||||
import com.project.movienight.domain.model.Film
|
||||
import com.project.movienight.domain.model.FilmLibraryEntry
|
||||
import io.mockk.every
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
@@ -22,83 +22,52 @@ import org.junit.jupiter.api.assertThrows
|
||||
import java.util.UUID
|
||||
|
||||
class FilmLibraryServiceTest {
|
||||
private lateinit var filmLibraryRepository: FilmLibraryRepositoryPort
|
||||
private lateinit var filmLibraryEntryRepository: FilmLibraryEntryRepositoryPort
|
||||
private lateinit var filmRepository: FilmRepositoryPort
|
||||
private lateinit var idGenerator: IdGenerator
|
||||
private lateinit var businessMetricsService: BusinessMetricsService
|
||||
private lateinit var businessMetricsService: BusinessMetricsPort
|
||||
private lateinit var filmLibraryService: FilmLibraryService
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
filmLibraryRepository = mockk()
|
||||
filmLibraryEntryRepository = mockk()
|
||||
filmRepository = mockk()
|
||||
idGenerator = mockk()
|
||||
businessMetricsService = mockk(relaxed = true)
|
||||
filmLibraryService = FilmLibraryService(filmLibraryRepository, idGenerator, businessMetricsService)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw EntityNotFoundException when creating library for user with no entries`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val command = CreateFilmLibraryCommand(userId = userId, name = "My Films")
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns emptyList()
|
||||
|
||||
assertThrows<EntityNotFoundException> {
|
||||
filmLibraryService.create(command)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 0) { idGenerator.generateId() }
|
||||
verify(exactly = 0) { filmLibraryRepository.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should return existing library when user already has one`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val existingLibrary =
|
||||
FilmLibrary(
|
||||
id = UUID.randomUUID(),
|
||||
userId = userId,
|
||||
filmId = UUID.randomUUID(),
|
||||
comment = "Existing Library",
|
||||
isViewed = false,
|
||||
filmLibraryService =
|
||||
FilmLibraryService(
|
||||
filmLibraryEntryRepository,
|
||||
filmRepository,
|
||||
idGenerator,
|
||||
businessMetricsService,
|
||||
)
|
||||
val command = CreateFilmLibraryCommand(userId = userId, name = "New Library")
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
|
||||
|
||||
val result = filmLibraryService.create(command)
|
||||
|
||||
assertEquals(existingLibrary, result)
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 0) { idGenerator.generateId() }
|
||||
verify(exactly = 0) { filmLibraryRepository.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add film to new library when user has no library`() {
|
||||
fun `should add film as new library entry`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val filmId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
val entryId = UUID.randomUUID()
|
||||
val command = AddFilmToLibraryCommand(userId = userId, filmId = filmId)
|
||||
val expectedLibrary =
|
||||
FilmLibrary(
|
||||
id = libraryId,
|
||||
val expectedEntry =
|
||||
FilmLibraryEntry(
|
||||
id = entryId,
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
comment = null,
|
||||
isViewed = false,
|
||||
)
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns emptyList()
|
||||
every { idGenerator.generateId() } returns libraryId
|
||||
every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description")
|
||||
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null
|
||||
every { idGenerator.generateId() } returns entryId
|
||||
every {
|
||||
filmLibraryRepository.save(
|
||||
filmLibraryEntryRepository.save(
|
||||
match {
|
||||
it.userId == userId && it.filmId == filmId && it.comment == null && it.isViewed == false
|
||||
},
|
||||
)
|
||||
} returns expectedLibrary
|
||||
} returns expectedEntry
|
||||
|
||||
val result = filmLibraryService.addFilm(command)
|
||||
|
||||
@@ -106,62 +75,46 @@ class FilmLibraryServiceTest {
|
||||
assertEquals(filmId, result.filmId)
|
||||
assertEquals(userId, result.userId)
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
|
||||
verify(exactly = 1) { idGenerator.generateId() }
|
||||
verify(exactly = 1) { filmLibraryRepository.save(any()) }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.save(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should add film as a new library entry when another film already exists`() {
|
||||
fun `should reset viewed state when adding existing entry`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val oldFilmId = UUID.randomUUID()
|
||||
val newFilmId = UUID.randomUUID()
|
||||
val existingLibrary =
|
||||
FilmLibrary(
|
||||
val filmId = UUID.randomUUID()
|
||||
val existingEntry =
|
||||
FilmLibraryEntry(
|
||||
id = UUID.randomUUID(),
|
||||
userId = userId,
|
||||
filmId = oldFilmId,
|
||||
filmId = filmId,
|
||||
comment = "My Library",
|
||||
isViewed = true,
|
||||
)
|
||||
val command = AddFilmToLibraryCommand(userId = userId, filmId = newFilmId)
|
||||
val createdLibrary =
|
||||
FilmLibrary(
|
||||
id = UUID.randomUUID(),
|
||||
userId = userId,
|
||||
filmId = newFilmId,
|
||||
comment = null,
|
||||
isViewed = false,
|
||||
)
|
||||
val updatedEntry = existingEntry.copy(isViewed = false, watchedAt = null)
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
|
||||
every { idGenerator.generateId() } returns createdLibrary.id
|
||||
every {
|
||||
filmLibraryRepository.save(
|
||||
match {
|
||||
it.id == createdLibrary.id && it.userId == userId && it.filmId == newFilmId && it.isViewed == false
|
||||
},
|
||||
)
|
||||
} returns createdLibrary
|
||||
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry
|
||||
every { filmRepository.findById(filmId) } returns Film(filmId, "Film", "Description")
|
||||
every { filmLibraryEntryRepository.save(updatedEntry) } returns updatedEntry
|
||||
|
||||
val result = filmLibraryService.addFilm(command)
|
||||
val result = filmLibraryService.addFilm(AddFilmToLibraryCommand(userId = userId, filmId = filmId))
|
||||
|
||||
assertEquals(newFilmId, result.filmId)
|
||||
assertEquals(false, result.isViewed)
|
||||
assertEquals(updatedEntry, result)
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 1) { idGenerator.generateId() }
|
||||
verify(exactly = 1) { filmLibraryRepository.save(any()) }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
|
||||
verify(exactly = 0) { idGenerator.generateId() }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.save(updatedEntry) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should remove film from library successfully`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val filmId = UUID.randomUUID()
|
||||
val libraryId = UUID.randomUUID()
|
||||
val existingLibrary =
|
||||
FilmLibrary(
|
||||
id = libraryId,
|
||||
val entryId = UUID.randomUUID()
|
||||
val existingEntry =
|
||||
FilmLibraryEntry(
|
||||
id = entryId,
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
comment = "My Library",
|
||||
@@ -169,122 +122,70 @@ class FilmLibraryServiceTest {
|
||||
)
|
||||
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
|
||||
justRun { filmLibraryRepository.deleteById(libraryId) }
|
||||
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns existingEntry
|
||||
justRun { filmLibraryEntryRepository.deleteById(entryId) }
|
||||
|
||||
val result = filmLibraryService.removeFilm(command)
|
||||
|
||||
assertEquals(existingLibrary, result)
|
||||
assertEquals(existingEntry, result)
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 1) { filmLibraryRepository.deleteById(libraryId) }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.deleteById(entryId) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw EntityNotFoundException when removing film from non-existent library`() {
|
||||
fun `should throw EntityNotFoundException when removing non-existent entry`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val filmId = UUID.randomUUID()
|
||||
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId)
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns emptyList()
|
||||
every { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) } returns null
|
||||
|
||||
assertThrows<EntityNotFoundException> {
|
||||
filmLibraryService.removeFilm(command)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.findByUserIdAndFilmId(userId, filmId) }
|
||||
verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw DomainException when removing film that is not in library`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val libraryFilmId = UUID.randomUUID()
|
||||
val differentFilmId = UUID.randomUUID()
|
||||
val existingLibrary =
|
||||
FilmLibrary(
|
||||
id = UUID.randomUUID(),
|
||||
userId = userId,
|
||||
filmId = libraryFilmId,
|
||||
comment = "My Library",
|
||||
isViewed = false,
|
||||
)
|
||||
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = differentFilmId)
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
|
||||
|
||||
assertThrows<DomainException> {
|
||||
filmLibraryService.removeFilm(command)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw EntityNotFoundException when libraryId does not match`() {
|
||||
fun `should throw DomainException when entry id belongs to another film`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val filmId = UUID.randomUUID()
|
||||
val actualLibraryId = UUID.randomUUID()
|
||||
val wrongLibraryId = UUID.randomUUID()
|
||||
val existingLibrary =
|
||||
FilmLibrary(
|
||||
id = actualLibraryId,
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
comment = "My Library",
|
||||
isViewed = false,
|
||||
)
|
||||
val command =
|
||||
RemoveFilmFromLibraryCommand(
|
||||
userId = userId,
|
||||
filmId = filmId,
|
||||
libraryId = wrongLibraryId,
|
||||
)
|
||||
|
||||
every { filmLibraryRepository.findById(wrongLibraryId) } returns null
|
||||
|
||||
assertThrows<EntityNotFoundException> {
|
||||
filmLibraryService.removeFilm(command)
|
||||
}
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findById(wrongLibraryId) }
|
||||
verify(exactly = 0) { filmLibraryRepository.deleteById(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should get library successfully`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val existingLibrary =
|
||||
FilmLibrary(
|
||||
id = UUID.randomUUID(),
|
||||
val entryId = UUID.randomUUID()
|
||||
val existingEntry =
|
||||
FilmLibraryEntry(
|
||||
id = entryId,
|
||||
userId = userId,
|
||||
filmId = UUID.randomUUID(),
|
||||
comment = "My Library",
|
||||
isViewed = false,
|
||||
)
|
||||
val query = GetFilmLibraryQuery(userId = userId)
|
||||
val command = RemoveFilmFromLibraryCommand(userId = userId, filmId = filmId, entryId = entryId)
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns listOf(existingLibrary)
|
||||
every { filmLibraryEntryRepository.findById(entryId) } returns existingEntry
|
||||
|
||||
val result = filmLibraryService.getLibrary(query)
|
||||
assertThrows<DomainException> {
|
||||
filmLibraryService.removeFilm(command)
|
||||
}
|
||||
|
||||
assertEquals(existingLibrary, result)
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.findById(entryId) }
|
||||
verify(exactly = 0) { filmLibraryEntryRepository.deleteById(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should throw EntityNotFoundException when getting non-existent library`() {
|
||||
fun `should list entries by user`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val query = GetFilmLibraryQuery(userId = userId)
|
||||
val entries =
|
||||
listOf(
|
||||
FilmLibraryEntry(UUID.randomUUID(), userId, UUID.randomUUID(), null, false),
|
||||
)
|
||||
|
||||
every { filmLibraryRepository.findAll() } returns emptyList()
|
||||
every { filmLibraryEntryRepository.findByUserId(userId) } returns entries
|
||||
|
||||
assertThrows<EntityNotFoundException> {
|
||||
filmLibraryService.getLibrary(query)
|
||||
}
|
||||
assertEquals(entries, filmLibraryService.list(userId))
|
||||
|
||||
verify(exactly = 1) { filmLibraryRepository.findAll() }
|
||||
verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,13 @@ package com.project.movienight.application.services
|
||||
|
||||
import com.project.movienight.application.ports.input.CreateFilmCommand
|
||||
import com.project.movienight.application.ports.input.EditFilmCommand
|
||||
import com.project.movienight.application.ports.output.BusinessMetricsPort
|
||||
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.simple.SimpleMeterRegistry
|
||||
import io.mockk.every
|
||||
import io.mockk.justRun
|
||||
import io.mockk.mockk
|
||||
@@ -24,7 +24,7 @@ class FilmServiceTest {
|
||||
private lateinit var filmRepository: FilmRepositoryPort
|
||||
private lateinit var idGenerator: IdGenerator
|
||||
private lateinit var filmConfig: FilmServiceProperties
|
||||
private lateinit var meterRegistry: SimpleMeterRegistry
|
||||
private lateinit var businessMetricsService: BusinessMetricsPort
|
||||
private lateinit var filmService: FilmService
|
||||
|
||||
@BeforeEach
|
||||
@@ -32,8 +32,8 @@ class FilmServiceTest {
|
||||
filmRepository = mockk()
|
||||
idGenerator = mockk()
|
||||
filmConfig = mockk()
|
||||
meterRegistry = SimpleMeterRegistry()
|
||||
filmService = FilmService(filmRepository, idGenerator, filmConfig, meterRegistry)
|
||||
businessMetricsService = mockk(relaxed = true)
|
||||
filmService = FilmService(filmRepository, idGenerator, filmConfig, businessMetricsService)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -37,7 +37,7 @@ class UserServiceTest {
|
||||
fun `should create user successfully`() {
|
||||
val command = CreateUserCommand(name = "John Doe", email = "john@example.com")
|
||||
val userId = UUID.randomUUID()
|
||||
val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
|
||||
val expectedUser = User(id = userId, name = "John Doe", email = "john@example.com")
|
||||
|
||||
every { userConfig.isBlocked("John Doe") } returns false
|
||||
every { idGenerator.generateId() } returns userId
|
||||
@@ -74,8 +74,8 @@ class UserServiceTest {
|
||||
fun `should edit user successfully`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val command = EditUserCommand(name = "Jane Doe")
|
||||
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
|
||||
val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com", library = null)
|
||||
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com")
|
||||
val updatedUser = User(id = userId, name = "Jane Doe", email = "john@example.com")
|
||||
|
||||
every { userConfig.isBlocked("Jane Doe") } returns false
|
||||
every { userRepository.findById(userId) } returns existingUser
|
||||
@@ -128,7 +128,7 @@ class UserServiceTest {
|
||||
@Test
|
||||
fun `should delete user successfully`() {
|
||||
val userId = UUID.randomUUID()
|
||||
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com", library = null)
|
||||
val existingUser = User(id = userId, name = "John Doe", email = "john@example.com")
|
||||
|
||||
every { userRepository.findById(userId) } returns existingUser
|
||||
justRun { userRepository.deleteById(userId) }
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.project.movienight.config
|
||||
|
||||
import org.springframework.boot.test.context.TestConfiguration
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Primary
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
|
||||
import org.springframework.security.web.SecurityFilterChain
|
||||
|
||||
@TestConfiguration
|
||||
@EnableWebSecurity
|
||||
class TestSecurityConfiguration {
|
||||
@Bean
|
||||
@Primary
|
||||
fun testSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
|
||||
http
|
||||
.authorizeHttpRequests { auth ->
|
||||
auth.anyRequest().permitAll()
|
||||
}.csrf { csrf ->
|
||||
csrf.disable()
|
||||
}.headers { headers ->
|
||||
headers.frameOptions { frameOptions ->
|
||||
frameOptions.sameOrigin()
|
||||
}
|
||||
}
|
||||
|
||||
return http.build()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user