From 43be102d4e21c5c14bacbb6df5f42dfb25008f52 Mon Sep 17 00:00:00 2001 From: skettiks Date: Fri, 22 May 2026 12:09:44 +0300 Subject: [PATCH] =?UTF-8?q?refactor:=20=D0=BE=D0=B1=D0=BD=D0=BE=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20use=20case=20=D1=81=D0=BB=D0=BE=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=B8=D0=BD=D1=82=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8E=20Jellyfin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Основные изменения: укрупнены use case-интерфейсы, контроллеры переведены на цельные зависимости, логика доступных фильмов перенесена в FilmLibraryService, добавлены проверки существования фильма и улучшена обработка ошибок API. Метрики: FilmService больше не зависит напрямую от Micrometer для counters, используется BusinessMetricsPort; добавлены TimedAspect и duration-метрики через @Timed для create/edit/delete фильмов. Jellyfin: event handling переведен на транзакционную модель, sync учитывает runtime-ошибки, добавлены plugin-token/web-url настройки, webhook и sync endpoints защищены X-MovieNight-Plugin-Token. Плагин: добавлен Jellyfin plugin в plugins/jellyfin, backend принимает push-sync payload, sync-state доступен плагину, pull-sync вынесен в /api/integrations/jellyfin/pull-sync, README описывает фактический контракт. API и DTO: добавлены RecommendationResponse, ContentTypeParser, validation annotations, обработка validation errors и ResponseStatusException, endpoint /api/users/me. БД: добавлена V7 cleanup-миграция для legacy ratings/jellyfin_id объектов; V4/V5 в этот коммит не включались. Проверка: .\gradlew.bat check --stacktrace проходит полностью. --- build.gradle.kts | 1 + gradle/libs.versions.toml | 1 + plugins/jellyfin/.gitignore | 2 + .../Configuration/PluginConfiguration.cs | 50 +++ .../Configuration/config.js | 94 +++++ .../Configuration/configPage.html | 74 ++++ .../Configuration/ui.js | 339 ++++++++++++++++++ .../Controllers/MovieNightController.cs | 229 ++++++++++++ .../Jellyfin.Plugin.MovieNight.csproj | 36 ++ .../Jellyfin.Plugin.MovieNight/Plugin.cs | 73 ++++ .../PluginServiceRegistrator.cs | 21 ++ .../Services/MovieNightBackendClient.cs | 281 +++++++++++++++ .../Services/MovieNightEventPayload.cs | 31 ++ .../Services/MovieNightPeriodicSyncService.cs | 74 ++++ .../MovieNightPlaybackEventService.cs | 104 ++++++ .../Services/MovieNightSyncService.cs | 105 ++++++ plugins/jellyfin/README.md | 48 +++ plugins/jellyfin/build.yaml | 14 + .../adapters/jellyfin/JellyfinApiClient.kt | 30 +- .../metrics/BusinessMetricsService.kt | 39 +- .../adapters/persistence/entity/UserEntity.kt | 1 - ...itory.kt => FilmLibraryEntryRepository.kt} | 75 ++-- .../persistence/jdbc/FilmRepository.kt | 26 -- .../jdbc/JellyfinEventRepository.kt | 36 +- .../persistence/jdbc/UserRepository.kt | 108 +++++- .../security/CustomOAuth2UserService.kt | 25 +- .../security/SecurityConfiguration.kt | 5 + .../adapters/web/ApiExceptionHandler.kt | 41 +++ .../adapters/web/ContentTypeParser.kt | 10 + .../movienight/adapters/web/FilmController.kt | 41 +-- .../adapters/web/FilmLibraryController.kt | 97 ++--- .../adapters/web/FilmRatingController.kt | 13 +- .../adapters/web/JellyfinEventsController.kt | 38 +- .../adapters/web/JellyfinPluginAccess.kt | 20 ++ .../adapters/web/JellyfinSyncController.kt | 30 +- .../adapters/web/RecommendationController.kt | 3 +- .../movienight/adapters/web/UserController.kt | 34 +- .../adapters/web/UserPreferencesController.kt | 22 +- .../dto/request/CreateFilmLibraryRequest.kt | 5 - .../web/dto/request/CreateFilmRequest.kt | 15 + .../web/dto/request/CreateUserRequest.kt | 9 + .../web/dto/request/EditFilmRequest.kt | 15 + .../web/dto/request/EditUserRequest.kt | 6 + .../web/dto/request/JellyfinEventRequest.kt | 5 + .../web/dto/request/JellyfinSyncRequest.kt | 55 +++ .../web/dto/request/RateFilmRequest.kt | 7 + .../dto/response/FilmLibraryEntryResponse.kt | 26 ++ .../web/dto/response/FilmLibraryResponse.kt | 25 -- .../ports/input/FilmLibraryUseCase.kt | 42 +-- .../ports/input/FilmRatingUseCase.kt | 8 +- .../application/ports/input/FilmUseCase.kt | 38 +- .../ports/input/JellyfinUseCase.kt | 47 +++ .../ports/input/UserPreferencesUseCase.kt | 8 +- .../application/ports/input/UserUseCase.kt | 32 +- .../ports/output/BusinessMetricsPort.kt | 30 ++ .../output/FilmLibraryEntryRepositoryPort.kt | 21 ++ .../ports/output/FilmLibraryRepositoryPort.kt | 19 - .../ports/output/FilmRepositoryPort.kt | 2 - .../ports/output/JellyfinCatalogPort.kt | 30 ++ .../ports/output/JellyfinEventStorePort.kt | 17 + .../ports/output/UserRepositoryPort.kt | 14 + .../services/FilmLibraryService.kt | 95 +++-- .../application/services/FilmRatingService.kt | 10 +- .../application/services/FilmService.kt | 225 +++++------- .../services/JellyfinEventService.kt | 66 ++-- .../services/JellyfinSyncService.kt | 174 +++++++-- .../RecommendationOnboardingService.kt | 14 +- .../services/RecommendationService.kt | 14 +- .../services/UserPreferencesService.kt | 6 +- .../application/services/UserService.kt | 13 +- .../config/JellyfinIntegrationProperties.kt | 2 +- .../movienight/config/MetricsConfiguration.kt | 12 + .../{FilmLibrary.kt => FilmLibraryEntry.kt} | 2 +- .../project/movienight/domain/model/User.kt | 1 - src/main/resources/application.yaml | 1 + src/main/resources/db/ER.md | 59 +++ .../migration/V9__cleanup_legacy_schema.sql | 11 + .../com/project/movienight/ClassLoaderTest.kt | 17 + .../entity/UserEntityMappingTest.kt | 4 - ...mLibraryEntryRepositoryIntegrationTest.kt} | 78 ++-- .../jdbc/UserRepositoryIntegrationTest.kt | 53 ++- .../adapters/web/FilmControllerSearchTest.kt | 26 +- .../services/FilmLibraryServiceTest.kt | 249 ++++--------- .../application/services/FilmServiceTest.kt | 8 +- .../application/services/UserServiceTest.kt | 8 +- .../config/TestSecurityConfiguration.kt | 29 ++ 86 files changed, 2920 insertions(+), 1004 deletions(-) create mode 100644 plugins/jellyfin/.gitignore create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs create mode 100644 plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs create mode 100644 plugins/jellyfin/README.md create mode 100644 plugins/jellyfin/build.yaml rename src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepository.kt => FilmLibraryEntryRepository.kt} (60%) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAccess.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt delete mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt delete mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt create mode 100644 src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt create mode 100644 src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt rename src/main/kotlin/com/project/movienight/domain/model/{FilmLibrary.kt => FilmLibraryEntry.kt} (89%) create mode 100644 src/main/resources/db/migration/V9__cleanup_legacy_schema.sql create mode 100644 src/test/kotlin/com/project/movienight/ClassLoaderTest.kt rename src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/{FilmLibraryRepositoryIntegrationTest.kt => FilmLibraryEntryRepositoryIntegrationTest.kt} (61%) create mode 100644 src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt diff --git a/build.gradle.kts b/build.gradle.kts index c0f9dfd..37564ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 72e81e2..8dbb373 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" } diff --git a/plugins/jellyfin/.gitignore b/plugins/jellyfin/.gitignore new file mode 100644 index 0000000..5967294 --- /dev/null +++ b/plugins/jellyfin/.gitignore @@ -0,0 +1,2 @@ +**/bin/ +**/obj/ diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..2e97960 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/PluginConfiguration.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.MovieNight.Configuration; + +/// +/// MovieNight plugin settings persisted by Jellyfin. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Gets or sets a value indicating whether integration calls are enabled. + /// + public bool Enabled { get; set; } + + /// + /// Gets or sets the MovieNight backend base URL. + /// + public string BackendBaseUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the backend plugin token. + /// + public string ApiToken { get; set; } = string.Empty; + + /// + /// Gets or sets the periodic sync interval in minutes. + /// + public int SyncIntervalMinutes { get; set; } = 30; + + /// + /// Gets or sets a value indicating whether playback stop events are pushed to MovieNight. + /// + public bool EnablePlaybackEvents { get; set; } = true; + + /// + /// Gets or sets a value indicating whether periodic backend sync is enabled. + /// + public bool EnablePeriodicSync { get; set; } = true; + + /// + /// Gets or sets enabled Jellyfin library ids. Empty means all libraries. + /// + public List EnabledLibraryIds { get; set; } = new(); + + /// + /// Gets or sets the path where .strm files will be created. + /// + public string StrmOutputPath { get; set; } = string.Empty; +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js new file mode 100644 index 0000000..7e38881 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/config.js @@ -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(); + }); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html new file mode 100644 index 0000000..adb20db --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/configPage.html @@ -0,0 +1,74 @@ + + + + MovieNight + + +
+
+
+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
Directory where .strm files will be created for new films.
+
+ + + + + + + +
+ +
+ +
+ +
+ +
+

UI Integration

+

To enable the "Recommend Film" button and Rating UI in the main Jellyfin interface, you must add the following script URL to your Jellyfin Custom JavaScript setting (Dashboard > General):

+ +
+
+
+
+
+ + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js new file mode 100644 index 0000000..4786f13 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -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 = `${text}`; + 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 = ` +
+ +
+ `; + 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 = ` +
+

MovieNight

+ +
+
+ `; + const btnContainer = section.querySelector('.movieNightBtnContainer'); + btnContainer.appendChild(createTextButton('Recommend Film', 'btnMovieNightRecommend', showRecommendation)); + btnContainer.appendChild(createTextButton('Add Movie (STRM)', 'btnMovieNightAddMovie', showAddMovieDialog)); + btnContainer.appendChild(createTextButton('Sync Library', 'btnMovieNightSync', triggerSync)); + + 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 = ` +

${title}

+
+ + `; + return dialog; + } + + async function showRatingDialog(itemId) { + const overlay = createOverlay(); + const dialog = createDialogBase('Rate on MovieNight'); + const content = dialog.querySelector('.dialog-content'); + + content.innerHTML = `
`; + const grid = content.querySelector('.rating-grid'); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + for (let i = 1; i <= 10; i++) { + const btn = document.createElement('button'); + btn.type = 'button'; btn.is = 'emby-button'; + btn.className = 'emby-button raised'; + btn.innerText = i; + btn.style.padding = '0.8em 0'; + btn.style.textAlign = 'center'; + btn.style.display = 'flex'; + btn.style.alignItems = 'center'; + btn.style.justifyContent = 'center'; + btn.style.fontSize = '1.2em'; + btn.onclick = async () => { cleanup(); await submitRating(itemId, i); }; + grid.appendChild(btn); + } + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + } + + async function showAddMovieDialog() { + const overlay = createOverlay(); + const dialog = createDialogBase('Add Movie (STRM)'); + const content = dialog.querySelector('.dialog-content'); + const footer = dialog.querySelector('.dialog-footer'); + + content.innerHTML = ` +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+ `; + + const btnAdd = document.createElement('button'); + btnAdd.className = 'emby-button raised button-submit'; + btnAdd.style.flex = '2'; + btnAdd.style.backgroundColor = '#0064d2'; + btnAdd.innerHTML = 'Add Film'; + footer.insertBefore(btnAdd, footer.firstChild); + + const cleanup = () => { if (overlay.parentNode) document.body.removeChild(overlay); }; + + btnAdd.onclick = async () => { + const title = dialog.querySelector('.txtTitle').value; + const year = dialog.querySelector('.txtYear').value; + const imdbId = dialog.querySelector('.txtImdb').value; + const url = dialog.querySelector('.txtUrl').value; + if (!title) return; + cleanup(); + await addMovie(title, url, year, imdbId); + }; + + dialog.querySelector('.btnCancel').onclick = cleanup; + overlay.onclick = (e) => { if (e.target === overlay) cleanup(); }; + overlay.appendChild(dialog); + document.body.appendChild(overlay); + dialog.querySelector('.txtTitle').focus(); + } + + async function 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(); +})(); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs new file mode 100644 index 0000000..58b51ac --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -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; + +/// +/// Admin endpoints for the MovieNight plugin. +/// +[ApiController] +[Route("MovieNight")] +public class MovieNightController : ControllerBase +{ + private readonly MovieNightBackendClient _backendClient; + private readonly MovieNightSyncService _syncService; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightController( + MovieNightBackendClient backendClient, + MovieNightSyncService syncService) + { + _backendClient = backendClient; + _syncService = syncService; + } + + /// + /// Ping endpoint for connectivity checks. + /// + [HttpGet("Ping")] + public ActionResult Ping() => Ok("Pong"); + + /// + /// Returns plugin status. + /// + /// Status response. + [HttpGet("Status")] + [Authorize] + public ActionResult GetStatus() + { + var configuration = Plugin.Instance?.Configuration; + return new MovieNightPluginStatus( + Enabled: configuration?.Enabled ?? false, + BackendBaseUrl: configuration?.BackendBaseUrl ?? string.Empty, + PeriodicSyncEnabled: configuration?.EnablePeriodicSync ?? false, + PlaybackEventsEnabled: configuration?.EnablePlaybackEvents ?? false, + SyncIntervalMinutes: configuration?.SyncIntervalMinutes ?? 30); + } + + /// + /// Tests backend connectivity. + /// + /// Cancellation token. + /// Connection result. + [HttpPost("TestConnection")] + [Authorize] + public async Task> TestConnection(CancellationToken cancellationToken) + { + return await _backendClient.TestConnectionAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Triggers backend sync. + /// + /// Cancellation token. + /// Backend response. + [HttpPost("Sync")] + [Authorize] + public async Task> Sync(CancellationToken cancellationToken) + { + await _syncService.PerformSyncAsync(cancellationToken).ConfigureAwait(false); + return Ok("Sync triggered"); + } + + /// + /// Gets backend sync state. + /// + /// Cancellation token. + /// Backend response. + [HttpGet("SyncState")] + [Authorize] + public async Task> SyncState(CancellationToken cancellationToken) + { + return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Gets recommendations for the current user. + /// + [HttpGet("Users/{userId}/Recommendations")] + [Authorize] + public async Task> 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); + } + + /// + /// Posts a rating for a film. + /// + [HttpPost("Users/{userId}/Ratings/Films/{filmId}")] + [Authorize] + public async Task 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(); + } + + /// + /// Marks a film as viewed. + /// + [HttpPost("Users/{userId}/Library/Films/{filmId}/Viewed")] + [Authorize] + public async Task MarkViewed( + [FromRoute] string userId, + [FromRoute] string filmId, + [FromBody] ViewedRequest request, + CancellationToken cancellationToken) + { + await _backendClient.MarkViewedAsync(userId, filmId, request.WatchedAt, cancellationToken).ConfigureAwait(false); + return Ok(); + } + + /// + /// Creates a new film by generating a .strm file in a folder-per-movie structure. + /// Structure: Movie Name (Year) [imdbid-ttXXXXXXX]/Movie Name (Year) [imdbid-ttXXXXXXX].strm + /// + [HttpPost("Films")] + [Authorize] + public async Task CreateFilm([FromBody] CreateFilmRequest request) + { + var config = Plugin.Instance?.Configuration; + 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}"); + } + } +} + +/// +/// Create film request. +/// +public sealed record CreateFilmRequest(string Title, string? Url, int? Year, string? ImdbId); + +/// +/// Rating request. +/// +public sealed record RatingRequest(int Score, string? Note); + +/// +/// Viewed request. +/// +public sealed record ViewedRequest(DateTimeOffset? WatchedAt); + +/// +/// MovieNight plugin status response. +/// +/// Whether integration is enabled. +/// Backend base URL. +/// Whether periodic sync is enabled. +/// Whether playback events are enabled. +/// Sync interval in minutes. +public sealed record MovieNightPluginStatus( + bool Enabled, + string BackendBaseUrl, + bool PeriodicSyncEnabled, + bool PlaybackEventsEnabled, + int SyncIntervalMinutes); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj new file mode 100644 index 0000000..a8e6ec0 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Jellyfin.Plugin.MovieNight.csproj @@ -0,0 +1,36 @@ + + + + net9.0 + Jellyfin.Plugin.MovieNight + Jellyfin.Plugin.MovieNight + 1.0.0.1 + GPL-3.0-or-later + enable + true + false + + + + + + runtime + + + runtime + + + runtime + + + + + + + + + + + + + diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs new file mode 100644 index 0000000..73a5718 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Plugin.cs @@ -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; + +/// +/// MovieNight Jellyfin plugin. +/// +public class Plugin : BasePlugin, IHasWebPages +{ + /// + /// Initializes a new instance of the class. + /// + /// Application paths. + /// XML serializer. + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) + : base(applicationPaths, xmlSerializer) + { + Instance = this; + } + + /// + public override string Name => "MovieNight"; + + /// + public override Guid Id => Guid.Parse("42c72919-d6ff-4f62-bb8c-0fac39efafdb"); + + /// + public override string Description => "Bridges Jellyfin playback and sync signals to the MovieNight backend."; + + /// + /// Gets the current plugin instance. + /// + public static Plugin? Instance { get; private set; } + + /// + public IEnumerable 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) + } + ]; + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs new file mode 100644 index 0000000..b4fc366 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/PluginServiceRegistrator.cs @@ -0,0 +1,21 @@ +using Jellyfin.Plugin.MovieNight.Services; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Plugins; +using Microsoft.Extensions.DependencyInjection; + +namespace Jellyfin.Plugin.MovieNight; + +/// +/// Registers MovieNight services with Jellyfin. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) + { + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + serviceCollection.AddHostedService(); + serviceCollection.AddHostedService(); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs new file mode 100644 index 0000000..58b8e62 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -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; + +/// +/// Thin HTTP client for the MovieNight backend. +/// +public class MovieNightBackendClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + private readonly ILogger _logger; + private readonly HttpClient _httpClient; + + /// + /// Initializes a new instance of the class. + /// + /// Logger. + public MovieNightBackendClient(ILogger logger) + { + _logger = logger; + _httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(20) + }; + } + + /// + /// Calls backend health. + /// + /// Cancellation token. + /// Connection result. + public async Task 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 + { + ["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); + } + } + + /// + /// Pushes library sync data to the backend. + /// + /// Sync payload. + /// Cancellation token. + /// Backend response body. + public async Task 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; + } + + /// + /// Gets recommendations for a user. + /// + public async Task 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; + } + + /// + /// Posts a rating for a film. + /// + 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(); + } + + /// + /// Gets ratings for a user. + /// + public async Task 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; + } + + /// + /// Marks a film as viewed. + /// + 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(); + } + + /// + /// Reads backend sync state. + /// + /// Cancellation token. + /// Backend response body. + public async Task 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; + } + + /// + /// Pushes an event payload to the backend event endpoint. + /// + /// Event payload. + /// Cancellation token. + /// A task. + 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; + } +} + +/// +/// Backend connection result. +/// +/// Whether the call succeeded. +/// Result message. +public sealed record MovieNightConnectionResult(bool Success, string Message) +{ + /// + /// Creates a successful result. + /// + /// Connection result. + public static MovieNightConnectionResult Ok() => new(true, "OK"); + + /// + /// Creates a failed result. + /// + /// Failure message. + /// Connection result. + public static MovieNightConnectionResult Failed(string message) => new(false, message); +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs new file mode 100644 index 0000000..926199b --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightEventPayload.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.MovieNight.Services; + +/// +/// Event payload sent to MovieNight. +/// +/// Idempotency key. +/// Event type. +/// Event timestamp. +/// Jellyfin user id. +/// Jellyfin item id. +/// Payload version. +/// Extra event data. +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 Payload); diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs new file mode 100644 index 0000000..bd72c5e --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPeriodicSyncService.cs @@ -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; + +/// +/// Periodically asks MovieNight to run its current Jellyfin sync. +/// +public sealed class MovieNightPeriodicSyncService : BackgroundService +{ + private readonly MovieNightSyncService _syncService; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightPeriodicSyncService( + MovieNightSyncService syncService, + ILogger logger) + { + _syncService = syncService; + _logger = logger; + } + + /// + 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)); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs new file mode 100644 index 0000000..89a5f84 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightPlaybackEventService.cs @@ -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; + +/// +/// Subscribes to Jellyfin playback events and forwards thin payloads. +/// +public sealed class MovieNightPlaybackEventService : IHostedService +{ + private readonly ISessionManager _sessionManager; + private readonly MovieNightBackendClient _backendClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// Jellyfin session manager. + /// Backend client. + /// Logger. + public MovieNightPlaybackEventService( + ISessionManager sessionManager, + MovieNightBackendClient backendClient, + ILogger logger) + { + _sessionManager = sessionManager; + _backendClient = backendClient; + _logger = logger; + } + + /// + public Task StartAsync(CancellationToken cancellationToken) + { + _sessionManager.PlaybackStopped += OnPlaybackStopped; + return Task.CompletedTask; + } + + /// + 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 + { + ["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"); + } + }); + } +} diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs new file mode 100644 index 0000000..4c1ec05 --- /dev/null +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -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; + +/// +/// Service for synchronizing the Jellyfin library with MovieNight. +/// +public class MovieNightSyncService +{ + private readonly MovieNightBackendClient _backendClient; + private readonly ILibraryManager _libraryManager; + private readonly IUserManager _userManager; + private readonly IUserDataManager _userDataManager; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public MovieNightSyncService( + MovieNightBackendClient backendClient, + ILibraryManager libraryManager, + IUserManager userManager, + IUserDataManager userDataManager, + ILogger logger) + { + _backendClient = backendClient; + _libraryManager = libraryManager; + _userManager = userManager; + _userDataManager = userDataManager; + _logger = logger; + } + + /// + /// Performs a full library sync. + /// + public async Task PerformSyncAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting MovieNight library sync"); + + var config = Plugin.Instance?.Configuration; + var enabledLibraryIds = config?.EnabledLibraryIds ?? new List(); + + 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(); + + foreach (var item in items) + { + if (item is not Movie movie) continue; + + var jellyfinItemId = movie.Id.ToString("N"); + + var itemData = new Dictionary + { + ["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"); + } +} diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md new file mode 100644 index 0000000..299ad21 --- /dev/null +++ b/plugins/jellyfin/README.md @@ -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` diff --git a/plugins/jellyfin/build.yaml b/plugins/jellyfin/build.yaml new file mode 100644 index 0000000..3c846f3 --- /dev/null +++ b/plugins/jellyfin/build.yaml @@ -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. diff --git a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt index 71670be..2aea71c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt +++ b/src/main/kotlin/com/project/movienight/adapters/jellyfin/JellyfinApiClient.kt @@ -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, - val cast: List, - val directors: List, - val platformRating: Double?, - val imdbRating: Double?, - val externalUrl: String?, - val jellyfinLibraryId: String?, - val isPlayed: Boolean, -) - @Service class JellyfinApiClient( private val properties: JellyfinIntegrationProperties, private val objectMapper: ObjectMapper, -) { +) : JellyfinCatalogPort { private val httpClient: HttpClient = HttpClient .newBuilder() .connectTimeout(Duration.ofMillis(properties.requestTimeoutMs)) .build() - fun fetchUsers(): List = + override fun fetchUsers(): List = request("Users") .asItems() .mapNotNull { node -> @@ -51,7 +33,7 @@ class JellyfinApiClient( JellyfinRemoteUser(id = id, name = node.fieldText("Name") ?: id) } - fun fetchLibraryItems(userId: String): List = + override fun fetchLibraryItems(userId: String): List = @Suppress("MaxLineLength") request( "Users/$userId/Items?Recursive=true&IncludeItemTypes=Movie,Series,Episode&Fields=Genres,People,ProviderIds,Overview,ProductionYear,CommunityRating,OfficialRating,ParentId,UserData", diff --git a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt index b3912f1..973071c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/metrics/BusinessMetricsService.kt @@ -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() } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt index 58e2c3c..2a9c0c6 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/entity/UserEntity.kt @@ -20,7 +20,6 @@ fun UserEntity.toDomain(): User = id = id, name = name, email = email, - library = null, preferences = null, jellyfinUserId = jellyfinUserId, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt similarity index 60% rename from src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt rename to src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt index 9fa474d..beaf6cb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepository.kt @@ -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 = + 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 = + override fun findAll(): List = jdbc.query( "SELECT id, user_id, film_id, comment, is_viewed, watched_at FROM favorites", - filmLibraryRowMapper, + rowMapper, ) override fun deleteById(id: UUID) { diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt index b1d4ab2..cdfc6b9 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmRepository.kt @@ -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 = jdbc.query( """ diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt index 153eba7..a3ec81f 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/JellyfinEventRepository.kt @@ -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 } } diff --git a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt index ae34f89..02949ed 100644 --- a/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt +++ b/src/main/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepository.kt @@ -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 = 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() } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt index b64ea71..7b90df8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/CustomOAuth2UserService.kt @@ -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(), + ) } } } diff --git a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt index 0bcb1b3..92fb02c 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -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/**") diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt index 40b3362..29131e3 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/ApiExceptionHandler.kt @@ -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 { + 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 { diff --git a/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt new file mode 100644 index 0000000..cf8ed0b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/ContentTypeParser.kt @@ -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) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt index 9bec40b..1a35dea 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmController.kt @@ -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 = getAllFilmsUseCase.getAll().map { FilmResponse.fromDomain(it) } + fun getAll(): List = filmUseCase.getAll().map { FilmResponse.fromDomain(it) } @GetMapping("/search") fun searchByTitle( @RequestParam title: String, ): ResponseEntity { - val film = searchFilmByTitleUseCase.searchByTitle(title) + val film = filmUseCase.searchByTitle(title) return if (film != null) { ResponseEntity.ok(FilmResponse.fromDomain(film)) } else { diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt index 81115cd..e955445 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmLibraryController.kt @@ -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 = listFilmLibraryEntriesUseCase.list(userId).map { FilmLibraryResponse.fromDomain(it) } + ): List = + filmLibraryUseCase + .list(userId) + .map { entry -> FilmLibraryEntryResponse.fromDomain(entry) } + + @GetMapping("/entries") + fun listEntries( + @PathVariable userId: UUID, + ): List = 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 { - 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 = filmLibraryUseCase.listAvailableFilms(userId).map { FilmResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt index fec4889..b86b5f1 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/FilmRatingController.kt @@ -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 = getFilmRatingsUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } + ): List = filmRatingUseCase.getRatings(userId).map { FilmRatingResponse.fromDomain(it) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt index 81ee52c..14061ee 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -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, + ), ) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAccess.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAccess.kt new file mode 100644 index 0000000..bcd40db --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAccess.kt @@ -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") + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt index 74aac90..fa3d3c4 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -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 = jellyfinSyncService.getSyncStates() + fun syncState( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + ): List { + requireJellyfinIntegrationEnabled(properties) + requireJellyfinPluginToken(properties, token) + return jellyfinSyncUseCase.getSyncStates() + } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt index 6d5bdd9..cd37beb 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/RecommendationController.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt index e06954c..ee74235 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserController.kt @@ -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 = getAllUsersUseCase.getAll().map { UserResponse.fromDomain(it) } + fun getAll(): List = 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) } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt index 276565b..074fb15 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/UserPreferencesController.kt @@ -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) } } diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt deleted file mode 100644 index dc6cb11..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmLibraryRequest.kt +++ /dev/null @@ -1,5 +0,0 @@ -package com.project.movienight.adapters.web.dto.request - -data class CreateFilmLibraryRequest( - val name: String = "My films", -) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt index 82f7348..a07f049 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateFilmRequest.kt @@ -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 = emptyList(), val cast: List = emptyList(), val directors: List = 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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt index 73dcb0f..94308b2 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/CreateUserRequest.kt @@ -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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt index 60eddce..20d88e5 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditFilmRequest.kt @@ -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 = emptyList(), val cast: List = emptyList(), val directors: List = 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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt index 358e0e4..b459396 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/EditUserRequest.kt @@ -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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt index 68abfe8..973f7b8 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinEventRequest.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt new file mode 100644 index 0000000..ffa559a --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt @@ -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 = 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 = emptyList(), + val imdbId: String?, + @field:Valid + val userStates: List = 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, + ) +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt index 1f44e39..c2a8077 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/RateFilmRequest.kt @@ -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, ) diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt new file mode 100644 index 0000000..2a6d670 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryEntryResponse.kt @@ -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, + ) + } +} diff --git a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt b/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt deleted file mode 100644 index 90a339d..0000000 --- a/src/main/kotlin/com/project/movienight/adapters/web/dto/response/FilmLibraryResponse.kt +++ /dev/null @@ -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, - ) - } -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt index 3100547..39d9688 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmLibraryUseCase.kt @@ -1,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 + + fun listAvailableFilms(userId: UUID): List } 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 -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt index 37c8226..5a411a1 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmRatingUseCase.kt @@ -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 } data class RateFilmCommand( @@ -13,7 +15,3 @@ data class RateFilmCommand( val score: Int, val note: String? = null, ) - -interface GetFilmRatingsUseCase { - fun getRatings(userId: UUID): List -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt index 27098c7..c88bc2d 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/FilmUseCase.kt @@ -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 + + 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 -} - -interface SearchFilmByTitleUseCase { - fun searchByTitle(title: String): Film? -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt new file mode 100644 index 0000000..e8081b0 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -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?, +) + +interface JellyfinSyncUseCase { + fun syncNow(): JellyfinSyncSummary + + fun syncFromPlugin(command: JellyfinPluginSyncCommand): JellyfinSyncSummary + + fun getSyncStates(): List +} + +data class JellyfinPluginSyncCommand( + val items: List, +) + +data class JellyfinPluginSyncItem( + val jellyfinItemId: String, + val title: String, + val description: String?, + val year: Int?, + val genres: List, + val imdbId: String?, + val userStates: List, +) + +data class JellyfinPluginUserState( + val jellyfinUserId: String, + val isViewed: Boolean, + val lastPlayedAt: OffsetDateTime?, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt index b44820c..fcf539c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserPreferencesUseCase.kt @@ -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 = emptyList(), val contentTypes: List = emptyList(), ) - -interface GetUserPreferencesUseCase { - fun get(userId: UUID): UserPreferences? -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt index b066a4f..2cef36c 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/UserUseCase.kt @@ -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 } 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 -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt new file mode 100644 index 0000000..516fafd --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/BusinessMetricsPort.kt @@ -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() +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt new file mode 100644 index 0000000..825bb06 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryEntryRepositoryPort.kt @@ -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 + + fun findByUserIdAndFilmId( + userId: UUID, + filmId: UUID, + ): FilmLibraryEntry? + + fun findAll(): List + + fun deleteById(id: UUID) +} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt deleted file mode 100644 index 933f45c..0000000 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmLibraryRepositoryPort.kt +++ /dev/null @@ -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 - - fun deleteById(id: UUID) -} diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt index d18b2e6..1883fe9 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/FilmRepositoryPort.kt @@ -10,8 +10,6 @@ interface FilmRepositoryPort { fun findByJellyfinItemId(jellyfinItemId: String): Film? - fun findByJellyfinLibraryId(jellyfinLibraryId: String): Film? - fun findAll(): List fun findByTitle(title: String): Film? diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt new file mode 100644 index 0000000..0e48699 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinCatalogPort.kt @@ -0,0 +1,30 @@ +package com.project.movienight.application.ports.output + +import com.project.movienight.domain.model.ContentType + +interface JellyfinCatalogPort { + fun fetchUsers(): List + + fun fetchLibraryItems(userId: String): List +} + +data class JellyfinRemoteUser( + val id: String, + val name: String, +) + +data class JellyfinLibraryItemSnapshot( + val jellyfinItemId: String, + val title: String, + val description: String, + val contentType: ContentType, + val releaseYear: Int?, + val genres: List, + val cast: List, + val directors: List, + val platformRating: Double?, + val imdbRating: Double?, + val externalUrl: String?, + val jellyfinLibraryId: String?, + val isPlayed: Boolean, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt new file mode 100644 index 0000000..b7b7751 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/application/ports/output/JellyfinEventStorePort.kt @@ -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?, +) diff --git a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt index dd69728..980ec30 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/output/UserRepositoryPort.kt @@ -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 fun deleteById(id: UUID) diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt index 2924922..c78336e 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmLibraryService.kt @@ -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 = filmLibraryEntryRepository.findByUserId(userId) - override fun list(userId: UUID): List = filmLibraryRepository.findAll().filter { it.userId == userId } + override fun listAvailableFilms(userId: UUID): List { + 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()) + } } diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt index 738bada..0653171 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmRatingService.kt @@ -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") diff --git a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt index d69a6c8..30715f2 100644 --- a/src/main/kotlin/com/project/movienight/application/services/FilmService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/FilmService.kt @@ -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 = filmRepository.findAll() override fun searchByTitle(title: String): Film? = filmRepository.findByTitle(title) - - private val filmCreatedCounter = - Counter - .builder("film_created_total") - .description("Total number of created films") - .register(meterRegistry) - - private val filmEditedCounter = - Counter - .builder("film_edited_total") - .description("Total number of successfully edited films") - .register(meterRegistry) - - private val filmDeletedCounter = - Counter - .builder("film_deleted_total") - .description("Total number of successfully deleted films") - .register(meterRegistry) - - private val filmBlockedCounter = - Counter - .builder("films.blocked") - .description("Total blocked film operations") - .register(meterRegistry) - - private val createFilmTimer = - Timer - .builder("films.create.duration") - .description("Film creation duration") - .register(meterRegistry) - - private val editFilmTimer = - Timer - .builder("films.edit.duration") - .description("Film edit duration") - .register(meterRegistry) - - private val deleteFilmTimer = - Timer - .builder("films.delete.duration") - .description("Film deletion duration") - .register(meterRegistry) } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt index 64be033..b35c94c 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -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?, - ) { - 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 } diff --git a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt index 46faa88..dbcbe6f 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -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 = 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() + 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 = 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/" } } diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt index 8f9fe04..ee02a92 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationOnboardingService.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt index e8a7722..da8dd01 100644 --- a/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/RecommendationService.kt @@ -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, - libraryEntries: List, + libraryEntries: List, filmsById: Map, weights: UserRecommendationWeights, ): SparseVector { diff --git a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt index de388ce..02fb178 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserPreferencesService.kt @@ -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( diff --git a/src/main/kotlin/com/project/movienight/application/services/UserService.kt b/src/main/kotlin/com/project/movienight/application/services/UserService.kt index 684da5f..dc0e1e0 100644 --- a/src/main/kotlin/com/project/movienight/application/services/UserService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/UserService.kt @@ -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) diff --git a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt index a4dc555..efb1f22 100644 --- a/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt +++ b/src/main/kotlin/com/project/movienight/config/JellyfinIntegrationProperties.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt new file mode 100644 index 0000000..3971bc3 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/config/MetricsConfiguration.kt @@ -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) +} diff --git a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt similarity index 89% rename from src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt rename to src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt index 8d7861c..cc81c1a 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/FilmLibrary.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/FilmLibraryEntry.kt @@ -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, diff --git a/src/main/kotlin/com/project/movienight/domain/model/User.kt b/src/main/kotlin/com/project/movienight/domain/model/User.kt index 236a698..10bc566 100644 --- a/src/main/kotlin/com/project/movienight/domain/model/User.kt +++ b/src/main/kotlin/com/project/movienight/domain/model/User.kt @@ -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, ) diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index c2af423..bf2cba0 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -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} diff --git a/src/main/resources/db/ER.md b/src/main/resources/db/ER.md index 4fecb18..5c0a829 100644 --- a/src/main/resources/db/ER.md +++ b/src/main/resources/db/ER.md @@ -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 ``` diff --git a/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql new file mode 100644 index 0000000..9415580 --- /dev/null +++ b/src/main/resources/db/migration/V9__cleanup_legacy_schema.sql @@ -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); diff --git a/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt new file mode 100644 index 0000000..a963e59 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/ClassLoaderTest.kt @@ -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}") + } +} diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt index 4e65569..859d9a4 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/entity/UserEntityMappingTest.kt @@ -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() diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt similarity index 61% rename from src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt rename to src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt index d880f02..fa8c616 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/FilmLibraryEntryRepositoryIntegrationTest.kt @@ -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) } } diff --git a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt index 02b588a..7d0c2bd 100644 --- a/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/persistence/jdbc/UserRepositoryIntegrationTest.kt @@ -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) + } } diff --git a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt index 033cccd..04a794d 100644 --- a/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt +++ b/src/test/kotlin/com/project/movienight/adapters/web/FilmControllerSearchTest.kt @@ -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(), - editFilmUseCase = mockk(), - deleteFilmUseCase = mockk(), - getFilmByIdUseCase = mockk(), - getAllFilmsUseCase = mockk(), - 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) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt index 7145507..beb7ec6 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmLibraryServiceTest.kt @@ -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 { - 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 { 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 { - 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 { - 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 { + 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 { - filmLibraryService.getLibrary(query) - } + assertEquals(entries, filmLibraryService.list(userId)) - verify(exactly = 1) { filmLibraryRepository.findAll() } + verify(exactly = 1) { filmLibraryEntryRepository.findByUserId(userId) } } } diff --git a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt index e0f96be..5c48c2c 100644 --- a/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/FilmServiceTest.kt @@ -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 diff --git a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt index c54a909..ce94f2e 100644 --- a/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt +++ b/src/test/kotlin/com/project/movienight/application/services/UserServiceTest.kt @@ -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) } diff --git a/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt new file mode 100644 index 0000000..34ae9c7 --- /dev/null +++ b/src/test/kotlin/com/project/movienight/config/TestSecurityConfiguration.kt @@ -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() + } +} -- 2.54.0