From 92add56cf70f504193cba212f5dd6eddc0e76ed6 Mon Sep 17 00:00:00 2001 From: ITQ Date: Fri, 22 May 2026 18:04:28 +0300 Subject: [PATCH] fix(jellyfin): fixes in jellyfin integration --- .../Configuration/ui.js | 10 +- .../Controllers/MovieNightController.cs | 15 +- .../Services/MovieNightBackendClient.cs | 28 ++- .../Services/MovieNightSyncService.cs | 10 +- plugins/jellyfin/README.md | 32 ++- .../security/SecurityConfiguration.kt | 4 +- .../adapters/web/JellyfinEventsController.kt | 14 +- .../web/JellyfinPluginAuthenticator.kt | 21 ++ .../adapters/web/JellyfinPluginController.kt | 227 ++++++++++++++++++ .../adapters/web/JellyfinSyncController.kt | 63 ++++- .../web/dto/request/JellyfinSyncRequest.kt | 43 ++++ .../ports/input/JellyfinUseCase.kt | 33 +++ .../services/JellyfinEventService.kt | 15 +- .../services/JellyfinSyncService.kt | 188 ++++++++++++--- src/main/resources/application.yaml | 3 +- .../controllers/JellyfinPluginContractTest.kt | 146 +++++++++++ 16 files changed, 785 insertions(+), 67 deletions(-) create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt create mode 100644 src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt create mode 100644 src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js index e02f661..022ad33 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Configuration/ui.js @@ -398,8 +398,14 @@ 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()}`; + const states = Array.isArray(state) ? state : []; + const latest = states + .map(s => s.lastSuccessfulSyncAt || s.lastSyncedAt) + .filter(Boolean) + .sort() + .pop(); + if (latest) { + statusEl.innerText = `Last sync: ${new Date(latest).toLocaleString()}`; } } catch (err) { /* ignore */ } } diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs index 8c9ff73..7d94e7c 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Controllers/MovieNightController.cs @@ -87,9 +87,10 @@ public class MovieNightController : ControllerBase /// Backend response. [HttpGet("SyncState")] [Authorize] - public async Task> SyncState(CancellationToken cancellationToken) + public async Task SyncState(CancellationToken cancellationToken) { - return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); } /// @@ -97,14 +98,15 @@ public class MovieNightController : ControllerBase /// [HttpGet("Users/{userId}/Recommendations")] [Authorize] - public async Task> GetRecommendations( + 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); + var body = await _backendClient.GetRecommendationsAsync(userId, contentType, mood, limit, cancellationToken).ConfigureAwait(false); + return Content(body, "application/json"); } /// @@ -142,11 +144,12 @@ public class MovieNightController : ControllerBase /// [HttpGet("Users/{userId}/Preferences")] [Authorize] - public async Task> GetPreferences( + public async Task GetPreferences( [FromRoute] string userId, CancellationToken cancellationToken) { - return await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + var body = await _backendClient.GetPreferencesAsync(userId, cancellationToken).ConfigureAwait(false); + return body is null ? NotFound() : Content(body, "application/json"); } /// diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs index deca123..90381b9 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightBackendClient.cs @@ -40,7 +40,7 @@ public class MovieNightBackendClient { var payload = new MovieNightEventPayload( EventId: $"plugin-test:{Guid.NewGuid():N}", - EventType: "playback.stopped", + EventType: "plugin.test", OccurredAt: DateTimeOffset.UtcNow, JellyfinUserId: "movienight-plugin-test-user", ItemId: "movienight-plugin-test-item", @@ -95,11 +95,12 @@ public class MovieNightBackendClient /// public async Task GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken) { + userId = NormalizeJellyfinId(userId); 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}"); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/recommendations{query}"); if (request is null) return "Plugin is not configured."; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -113,7 +114,9 @@ public class MovieNightBackendClient /// 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}"); + userId = NormalizeJellyfinId(userId); + filmId = NormalizeJellyfinId(filmId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/ratings/items/{filmId}"); if (request is null) return; request.Content = JsonContent.Create(new { score, note }, options: JsonOptions); @@ -126,7 +129,8 @@ public class MovieNightBackendClient /// public async Task GetRatingsAsync(string userId, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/ratings"); + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/ratings"); if (request is null) return "[]"; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); @@ -140,7 +144,9 @@ public class MovieNightBackendClient /// public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed"); + userId = NormalizeJellyfinId(userId); + filmId = NormalizeJellyfinId(filmId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/library/items/{filmId}/viewed"); if (request is null) return; request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions); @@ -172,13 +178,15 @@ public class MovieNightBackendClient /// public async Task GetPreferencesAsync(string userId, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Get, $"/api/users/{userId}/preferences"); + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Get, $"/api/integrations/jellyfin/users/{userId}/preferences"); if (request is null) return null; using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + response.EnsureSuccessStatusCode(); return body; } @@ -187,7 +195,8 @@ public class MovieNightBackendClient /// public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken) { - var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/recommendation-onboarding"); + userId = NormalizeJellyfinId(userId); + var request = CreateRequest(HttpMethod.Post, $"/api/integrations/jellyfin/users/{userId}/recommendation-onboarding"); if (request is null) return; request.Content = JsonContent.Create(payload, options: JsonOptions); @@ -257,6 +266,11 @@ public class MovieNightBackendClient return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); } + private static string NormalizeJellyfinId(string value) + { + return Guid.TryParse(value, out var guid) ? guid.ToString("N") : value; + } + private static bool IsEnabled() { var configuration = Plugin.Instance?.Configuration; diff --git a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs index 4c1ec05..93da778 100644 --- a/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs +++ b/plugins/jellyfin/Jellyfin.Plugin.MovieNight/Services/MovieNightSyncService.cs @@ -64,6 +64,11 @@ public class MovieNightSyncService var items = _libraryManager.GetItemList(query); var users = _userManager.Users; + var syncUsers = users.Select(u => new + { + jellyfinUserId = u.Id.ToString("N"), + name = u.Username + }).ToList(); var syncItems = new List(); foreach (var item in items) @@ -71,11 +76,12 @@ public class MovieNightSyncService if (item is not Movie movie) continue; var jellyfinItemId = movie.Id.ToString("N"); + var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name; var itemData = new Dictionary { ["jellyfinItemId"] = jellyfinItemId, - ["title"] = movie.Name, + ["title"] = title, ["originalTitle"] = movie.OriginalTitle, ["description"] = movie.Overview, ["year"] = movie.ProductionYear, @@ -99,7 +105,7 @@ public class MovieNightSyncService syncItems.Add(itemData); } - await _backendClient.SyncAsync(new { items = syncItems }, cancellationToken).ConfigureAwait(false); + await _backendClient.SyncAsync(new { users = syncUsers, items = syncItems }, cancellationToken).ConfigureAwait(false); _logger.LogInformation("MovieNight library sync completed"); } } diff --git a/plugins/jellyfin/README.md b/plugins/jellyfin/README.md index 6a01e6d..2c3b06a 100644 --- a/plugins/jellyfin/README.md +++ b/plugins/jellyfin/README.md @@ -18,6 +18,34 @@ Current implemented calls: - `POST /api/integrations/jellyfin/sync` - `GET /api/integrations/jellyfin/sync-state` - `POST /api/integrations/jellyfin/events` +- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendations` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/ratings/items/{jellyfin_item_id}` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/library/items/{jellyfin_item_id}/viewed` +- `GET /api/integrations/jellyfin/users/{jellyfin_user_id}/preferences` +- `POST /api/integrations/jellyfin/users/{jellyfin_user_id}/recommendation-onboarding` + +Configure the backend with: + +- `JELLYFIN_INTEGRATION_ENABLED=true` +- `JELLYFIN_PLUGIN_TOKEN=` +- `JELLYFIN_WEB_URL=` + +`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`. + +Optional backend-pull sync values: + +- `JELLYFIN_BASE_URL=` +- `JELLYFIN_API_KEY=` + +The Jellyfin API key is only for backend-to-Jellyfin calls. The plugin token is a MovieNight shared secret for plugin-to-backend calls. + +Configure the plugin with: + +- Backend URL: MovieNight backend URL reachable from the Jellyfin server, for example `http://movienight-backend:8080` +- Plugin token: the exact `JELLYFIN_PLUGIN_TOKEN` value +- Enable MovieNight integration: checked +- Enable periodic backend sync: checked if the plugin should push library state on an interval +- Send playback stop events: checked if completed playback should mark films viewed in MovieNight Event requests use JSON with: @@ -31,4 +59,6 @@ Event requests use JSON with: 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 push Jellyfin users, items, and per-user watched states to the backend. The backend creates MovieNight users for new Jellyfin users using their Jellyfin id as the stable mapping key, upserts films by `jellyfinItemId`, and uses the Jellyfin-facing endpoints above for UI actions so Jellyfin ids do not have to match MovieNight UUIDs. Run "Sync Library" once after installing/configuring the plugin so recommendations, rating, and viewed actions can resolve Jellyfin items. + +The config page test action posts a small `plugin.test` event to `/api/integrations/jellyfin/events` and treats `200` as success and `401` as token/config failure. 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 92fb02c..4caacdd 100644 --- a/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt +++ b/src/main/kotlin/com/project/movienight/adapters/security/SecurityConfiguration.kt @@ -24,9 +24,7 @@ class SecurityConfiguration( .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") .permitAll() .requestMatchers( - "/api/integrations/jellyfin/events", - "/api/integrations/jellyfin/sync", - "/api/integrations/jellyfin/sync-state", + "/api/integrations/jellyfin/**", ).permitAll() .requestMatchers("/api/users/me") .authenticated() 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 0d7a294..2d33a64 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinEventsController.kt @@ -3,7 +3,6 @@ package com.project.movienight.adapters.web import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest 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 @@ -13,13 +12,12 @@ 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 jellyfinEventUseCase: JellyfinEventUseCase, - private val properties: JellyfinIntegrationProperties, + private val authenticator: JellyfinPluginAuthenticator, ) { private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) @@ -29,15 +27,7 @@ class JellyfinEventsController( @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @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") - } - } + authenticator.authenticate(token) log.debug( "Received Jellyfin event {} for user {} item {}", diff --git a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt new file mode 100644 index 0000000..c2bbb1b --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginAuthenticator.kt @@ -0,0 +1,21 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.config.JellyfinIntegrationProperties +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Component +import org.springframework.web.server.ResponseStatusException + +@Component +class JellyfinPluginAuthenticator( + private val properties: JellyfinIntegrationProperties, +) { + fun authenticate(token: String?) { + if (!properties.enabled) { + throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled") + } + + 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/JellyfinPluginController.kt b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt new file mode 100644 index 0000000..10bda37 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinPluginController.kt @@ -0,0 +1,227 @@ +package com.project.movienight.adapters.web + +import com.project.movienight.adapters.web.dto.request.RateFilmRequest +import com.project.movienight.adapters.web.dto.request.RecommendationOnboardingRequest +import com.project.movienight.adapters.web.dto.response.FilmLibraryEntryResponse +import com.project.movienight.adapters.web.dto.response.FilmRatingResponse +import com.project.movienight.adapters.web.dto.response.RecommendationOnboardingResponse +import com.project.movienight.adapters.web.dto.response.RecommendationResponse +import com.project.movienight.adapters.web.dto.response.UserPreferencesResponse +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingCommand +import com.project.movienight.application.ports.input.CompleteRecommendationOnboardingUseCase +import com.project.movienight.application.ports.input.FilmLibraryUseCase +import com.project.movienight.application.ports.input.FilmRatingUseCase +import com.project.movienight.application.ports.input.GetRecommendationsUseCase +import com.project.movienight.application.ports.input.MarkFilmViewedCommand +import com.project.movienight.application.ports.input.RateFilmCommand +import com.project.movienight.application.ports.input.RecommendationQuery +import com.project.movienight.application.ports.input.UserPreferencesUseCase +import com.project.movienight.application.ports.output.FilmRepositoryPort +import com.project.movienight.application.ports.output.IdGenerator +import com.project.movienight.application.ports.output.UserRepositoryPort +import com.project.movienight.config.JellyfinIntegrationProperties +import com.project.movienight.domain.exception.EntityNotFoundException +import com.project.movienight.domain.model.Film +import com.project.movienight.domain.model.RecommendationStyle +import com.project.movienight.domain.model.User +import jakarta.validation.Valid +import org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.GetMapping +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestHeader +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RequestParam +import org.springframework.web.bind.annotation.RestController +import org.springframework.web.server.ResponseStatusException +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.time.OffsetDateTime +import java.util.Locale +import java.util.UUID + +@RestController +@RequestMapping("/api/integrations/jellyfin") +class JellyfinPluginController( + private val authenticator: JellyfinPluginAuthenticator, + private val userRepository: UserRepositoryPort, + private val filmRepository: FilmRepositoryPort, + private val idGenerator: IdGenerator, + private val getRecommendationsUseCase: GetRecommendationsUseCase, + private val filmRatingUseCase: FilmRatingUseCase, + private val filmLibraryUseCase: FilmLibraryUseCase, + private val userPreferencesUseCase: UserPreferencesUseCase, + private val completeRecommendationOnboardingUseCase: CompleteRecommendationOnboardingUseCase, + private val jellyfinProperties: JellyfinIntegrationProperties, +) { + @GetMapping("/users/{jellyfinUserId}/recommendations") + fun recommend( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @RequestParam(required = false) contentType: String?, + @RequestParam(required = false) mood: String?, + @RequestParam(required = false, defaultValue = "false") libraryOnly: Boolean, + @RequestParam(required = false, defaultValue = "10") limit: Int, + ): List { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return getRecommendationsUseCase + .recommend( + RecommendationQuery( + userId = user.id, + contentType = parseOptionalContentType(contentType), + mood = mood, + libraryOnly = libraryOnly, + limit = limit, + ), + ).map { recommendation -> + RecommendationResponse.fromDomain( + recommendation = recommendation, + watchUrl = buildWatchUrl(recommendation.film.jellyfinItemId), + ) + } + } + + @PostMapping("/users/{jellyfinUserId}/ratings/items/{jellyfinItemId}") + fun rate( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @PathVariable jellyfinItemId: String, + @Valid @RequestBody request: RateFilmRequest, + ): FilmRatingResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + val film = resolveFilm(jellyfinItemId) + return FilmRatingResponse.fromDomain( + filmRatingUseCase.rate( + RateFilmCommand( + userId = user.id, + filmId = film.id, + score = request.score, + note = request.note, + ), + ), + ) + } + + @GetMapping("/users/{jellyfinUserId}/ratings") + fun ratings( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + ): List { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return filmRatingUseCase.getRatings(user.id).map { FilmRatingResponse.fromDomain(it) } + } + + @PostMapping("/users/{jellyfinUserId}/library/items/{jellyfinItemId}/viewed") + fun markViewed( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @PathVariable jellyfinItemId: String, + @RequestBody(required = false) request: JellyfinViewedRequest?, + ): FilmLibraryEntryResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + val film = resolveFilm(jellyfinItemId) + return FilmLibraryEntryResponse.fromDomain( + filmLibraryUseCase.markViewed( + MarkFilmViewedCommand( + userId = user.id, + filmId = film.id, + watchedAt = request?.watchedAt?.toLocalDateTime(), + ), + ), + ) + } + + @GetMapping("/users/{jellyfinUserId}/preferences") + fun preferences( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + ): UserPreferencesResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return userPreferencesUseCase.get(user.id)?.let { UserPreferencesResponse.fromDomain(it) } + ?: throw ResponseStatusException(HttpStatus.NOT_FOUND, "User preferences not found") + } + + @PostMapping("/users/{jellyfinUserId}/recommendation-onboarding") + fun completeOnboarding( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @PathVariable jellyfinUserId: String, + @RequestBody request: RecommendationOnboardingRequest, + ): RecommendationOnboardingResponse { + authenticator.authenticate(token) + val user = resolveOrCreateUser(jellyfinUserId) + return RecommendationOnboardingResponse.fromApplication( + completeRecommendationOnboardingUseCase.complete( + CompleteRecommendationOnboardingCommand( + userId = user.id, + weightedGenres = request.weightedGenres, + plotTypes = request.plotTypes, + eras = request.eras, + castAndDirectors = request.castAndDirectors, + moods = request.moods, + contentTypes = request.contentTypes.mapNotNull { runCatching { parseContentType(it) }.getOrNull() }, + likedFilmIds = request.likedFilmIds, + dislikedFilmIds = request.dislikedFilmIds, + libraryFilmIds = request.libraryFilmIds, + watchedFilmIds = request.watchedFilmIds, + recommendationStyle = parseRecommendationStyle(request.recommendationStyle), + ), + ), + ) + } + + private fun resolveOrCreateUser(jellyfinUserId: String): User = + normalizeJellyfinId(jellyfinUserId).let { normalizedId -> + userRepository.findByJellyfinUserId(normalizedId) + ?: userRepository.save( + User( + id = idGenerator.generateId(), + name = "Jellyfin User", + email = syntheticJellyfinEmail(normalizedId), + jellyfinUserId = normalizedId, + ), + ) + } + + private fun resolveFilm(jellyfinItemId: String): Film = + normalizeJellyfinId(jellyfinItemId).let { normalizedId -> + filmRepository.findByJellyfinItemId(normalizedId) + ?: throw EntityNotFoundException(entity = "Jellyfin item", id = jellyfinItemId) + } + + private fun buildWatchUrl(jellyfinItemId: String?): String? { + if (jellyfinItemId.isNullOrBlank() || jellyfinProperties.webUrl.isBlank()) { + return null + } + + val baseUrl = jellyfinProperties.webUrl.trimEnd('/') + val encodedItemId = URLEncoder.encode(jellyfinItemId, StandardCharsets.UTF_8) + return "$baseUrl/web/#/details?id=$encodedItemId" + } + + private fun parseRecommendationStyle(value: String): RecommendationStyle = + runCatching { RecommendationStyle.valueOf(value.uppercase()) } + .getOrDefault(RecommendationStyle.BALANCED) + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) + + private fun syntheticJellyfinEmail(jellyfinUserId: String): String { + val safeId = + jellyfinUserId + .lowercase(Locale.getDefault()) + .replace(Regex("[^a-z0-9._%+-]"), "-") + .take(240) + return "jellyfin-$safeId@movienight.local" + } +} + +data class JellyfinViewedRequest( + val watchedAt: OffsetDateTime? = null, +) 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 aa107b7..29d5585 100644 --- a/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt +++ b/src/main/kotlin/com/project/movienight/adapters/web/JellyfinSyncController.kt @@ -1,10 +1,18 @@ package com.project.movienight.adapters.web +import com.project.movienight.adapters.web.dto.request.JellyfinSyncRequest +import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand +import com.project.movienight.application.ports.input.JellyfinSyncItemCommand import com.project.movienight.application.ports.input.JellyfinSyncUseCase +import com.project.movienight.application.ports.input.JellyfinSyncUserCommand +import com.project.movienight.application.ports.input.JellyfinSyncUserStateCommand 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 @@ -12,10 +20,61 @@ import org.springframework.web.bind.annotation.RestController @RequestMapping("/api/integrations/jellyfin") class JellyfinSyncController( private val jellyfinSyncUseCase: JellyfinSyncUseCase, + private val authenticator: JellyfinPluginAuthenticator, ) { @PostMapping("/sync") - fun syncNow(): JellyfinSyncSummary = jellyfinSyncUseCase.syncNow() + fun syncNow( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + @Valid @RequestBody(required = false) request: JellyfinSyncRequest?, + ): JellyfinSyncSummary { + authenticator.authenticate(token) + return if (request == null) { + jellyfinSyncUseCase.syncNow() + } else { + jellyfinSyncUseCase.ingest(request.toCommand()) + } + } @GetMapping("/sync-state") - fun syncState(): List = jellyfinSyncUseCase.getSyncStates() + fun syncState( + @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, + ): List { + authenticator.authenticate(token) + return jellyfinSyncUseCase.getSyncStates() + } + + private fun JellyfinSyncRequest.toCommand(): IngestJellyfinSyncCommand = + IngestJellyfinSyncCommand( + users = + users.map { user -> + JellyfinSyncUserCommand( + jellyfinUserId = user.jellyfinUserId, + name = user.name, + ) + }, + items = + items.map { item -> + JellyfinSyncItemCommand( + jellyfinItemId = item.jellyfinItemId, + title = item.title, + originalTitle = item.originalTitle, + description = item.description, + year = item.year, + genres = item.genres, + imdbId = item.imdbId, + tmdbId = item.tmdbId, + jellyfinLibraryId = item.jellyfinLibraryId, + userStates = + item.userStates.map { state -> + JellyfinSyncUserStateCommand( + jellyfinUserId = state.jellyfinUserId, + isViewed = state.isViewed, + playCount = state.playCount, + lastPlayedAt = state.lastPlayedAt, + userRating = state.userRating, + ) + }, + ) + }, + ) } 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..d916f71 --- /dev/null +++ b/src/main/kotlin/com/project/movienight/adapters/web/dto/request/JellyfinSyncRequest.kt @@ -0,0 +1,43 @@ +package com.project.movienight.adapters.web.dto.request + +import jakarta.validation.Valid +import jakarta.validation.constraints.NotBlank +import java.time.OffsetDateTime + +data class JellyfinSyncRequest( + @field:Valid + val users: List = emptyList(), + @field:Valid + val items: List = emptyList(), +) + +data class JellyfinSyncUserRequest( + @field:NotBlank + val jellyfinUserId: String, + val name: String? = null, +) + +data class JellyfinSyncItemRequest( + @field:NotBlank + val jellyfinItemId: String, + @field:NotBlank + val title: String, + val originalTitle: String? = null, + val description: String? = null, + val year: Int? = null, + val genres: List = emptyList(), + val imdbId: String? = null, + val tmdbId: String? = null, + val jellyfinLibraryId: String? = null, + @field:Valid + val userStates: List = emptyList(), +) + +data class JellyfinSyncUserStateRequest( + @field:NotBlank + val jellyfinUserId: String, + val isViewed: Boolean = false, + val playCount: Int = 0, + val lastPlayedAt: OffsetDateTime? = null, + val userRating: Double? = null, +) 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 index b28e68d..6be11a2 100644 --- a/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt +++ b/src/main/kotlin/com/project/movienight/application/ports/input/JellyfinUseCase.kt @@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand( interface JellyfinSyncUseCase { fun syncNow(): JellyfinSyncSummary + fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary + fun getSyncStates(): List } + +data class IngestJellyfinSyncCommand( + val users: List = emptyList(), + val items: List = emptyList(), +) + +data class JellyfinSyncUserCommand( + val jellyfinUserId: String, + val name: String?, +) + +data class JellyfinSyncItemCommand( + val jellyfinItemId: String, + val title: String, + val originalTitle: String?, + val description: String?, + val year: Int?, + val genres: List, + val imdbId: String?, + val tmdbId: String?, + val jellyfinLibraryId: String?, + val userStates: List, +) + +data class JellyfinSyncUserStateCommand( + val jellyfinUserId: String, + val isViewed: Boolean, + val playCount: Int, + val lastPlayedAt: OffsetDateTime?, + val userRating: Double?, +) 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 b35c94c..2747229 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinEventService.kt @@ -12,6 +12,7 @@ import com.project.movienight.application.ports.output.JellyfinEventStorePort import com.project.movienight.application.ports.output.UserRepositoryPort import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional +import java.util.UUID @Service class JellyfinEventService( @@ -26,6 +27,8 @@ class JellyfinEventService( @Transactional override fun handle(command: HandleJellyfinEventCommand) { + val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId) + val jellyfinItemId = normalizeJellyfinId(command.itemId) val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } val inserted = jellyfinEventStore.save( @@ -34,8 +37,8 @@ class JellyfinEventService( serverId = command.serverId, eventType = command.eventType, occurredAt = command.occurredAt, - jellyfinUserId = command.jellyfinUserId, - jellyfinItemId = command.itemId, + jellyfinUserId = jellyfinUserId, + jellyfinItemId = jellyfinItemId, payload = payloadJson, ), ) @@ -45,13 +48,13 @@ class JellyfinEventService( try { if (playbackEventTypes.contains(command.eventType)) { - val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId) + val localUser = userRepository.findByJellyfinUserId(jellyfinUserId) if (localUser == null) { businessMetricsService.recordJellyfinUnmappedUser() return } - val film = filmRepository.findByJellyfinItemId(command.itemId) + val film = filmRepository.findByJellyfinItemId(jellyfinItemId) if (film == null) { businessMetricsService.recordBackendWriteFailure() return @@ -72,4 +75,8 @@ class JellyfinEventService( throw ex } } + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) } 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 cc852d4..56fe3fe 100644 --- a/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt +++ b/src/main/kotlin/com/project/movienight/application/services/JellyfinSyncService.kt @@ -1,5 +1,6 @@ package com.project.movienight.application.services +import com.project.movienight.application.ports.input.IngestJellyfinSyncCommand import com.project.movienight.application.ports.input.JellyfinSyncUseCase import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort @@ -10,15 +11,18 @@ import com.project.movienight.application.ports.output.JellyfinLibraryItemSnapsh 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.FilmLibraryEntry import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncSummary +import com.project.movienight.domain.model.User import org.springframework.scheduling.annotation.Scheduled import org.springframework.stereotype.Service import java.time.Duration import java.time.Instant import java.time.LocalDateTime +import java.util.Locale import java.util.UUID @Service @@ -56,6 +60,21 @@ class JellyfinSyncService( override fun getSyncStates(): List = syncStateRepository.findAll() + override fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary { + if (!properties.enabled) { + return JellyfinSyncSummary(syncedUsers = 0, skippedUsers = 0, syncedItems = 0, durationMs = 0) + } + + return try { + ingestPluginSync(command) + } catch ( + @Suppress("TooGenericExceptionCaught") ex: RuntimeException, + ) { + businessMetricsService.recordJellyfinSyncFailure() + throw ex + } + } + private fun runSync(): JellyfinSyncSummary { val startedAt = Instant.now() val remoteUsers = jellyfinCatalog.fetchUsers() @@ -63,7 +82,7 @@ class JellyfinSyncService( userRepository .findAll() .mapNotNull { user -> - user.jellyfinUserId?.let { it to user } + user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } }.toMap() var syncedUsers = 0 @@ -71,7 +90,7 @@ class JellyfinSyncService( var syncedItems = 0 remoteUsers.forEach { remoteUser -> - val localUser = localUsersByJellyfinId[remoteUser.id] + val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)] if (localUser == null) { skippedUsers += 1 return@forEach @@ -123,34 +142,39 @@ class JellyfinSyncService( } private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { + val normalizedItem = + item.copy( + jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId), + jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId), + ) val film = - filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, + filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy( + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, ) ?: Film( id = idGenerator.generateId(), - title = item.title, - description = item.description, - contentType = item.contentType, - releaseYear = item.releaseYear, - genres = item.genres, - cast = item.cast, - directors = item.directors, - imdbRating = item.imdbRating, - platformRating = item.platformRating, - externalUrl = item.externalUrl, - jellyfinItemId = item.jellyfinItemId, - jellyfinLibraryId = item.jellyfinLibraryId, + title = normalizedItem.title, + description = normalizedItem.description, + contentType = normalizedItem.contentType, + releaseYear = normalizedItem.releaseYear, + genres = normalizedItem.genres, + cast = normalizedItem.cast, + directors = normalizedItem.directors, + imdbRating = normalizedItem.imdbRating, + platformRating = normalizedItem.platformRating, + externalUrl = normalizedItem.externalUrl, + jellyfinItemId = normalizedItem.jellyfinItemId, + jellyfinLibraryId = normalizedItem.jellyfinLibraryId, ) return filmRepository.save(film) @@ -176,4 +200,114 @@ class JellyfinSyncService( ), ) } + + private fun ingestPluginSync(command: IngestJellyfinSyncCommand): JellyfinSyncSummary { + val startedAt = Instant.now() + upsertPluginUsers(command) + val localUsersByJellyfinId = + userRepository + .findAll() + .mapNotNull { user -> user.jellyfinUserId?.let { normalizeJellyfinId(it) to user } } + .toMap() + + val skippedUserIds = mutableSetOf() + val syncedCountsByUserId = mutableMapOf() + + command.items.forEach { item -> + val savedFilm = + upsertFilm( + JellyfinLibraryItemSnapshot( + jellyfinItemId = item.jellyfinItemId, + title = item.title, + description = item.description ?: item.originalTitle ?: "", + contentType = ContentType.FILM, + releaseYear = item.year, + genres = item.genres, + cast = emptyList(), + directors = emptyList(), + platformRating = null, + imdbRating = null, + externalUrl = item.imdbId?.let { "https://www.imdb.com/title/$it/" }, + jellyfinLibraryId = item.jellyfinLibraryId, + isPlayed = false, + ), + ) + + item.userStates.forEach { state -> + val stateUserId = normalizeJellyfinId(state.jellyfinUserId) + val localUser = localUsersByJellyfinId[stateUserId] + if (localUser == null) { + skippedUserIds += stateUserId + return@forEach + } + + syncedCountsByUserId[localUser.id] = syncedCountsByUserId.getOrDefault(localUser.id, 0) + 1 + if (state.isViewed || state.playCount > 0) { + markFilmViewed( + userId = localUser.id, + filmId = savedFilm.id, + watchedAt = state.lastPlayedAt?.toLocalDateTime() ?: LocalDateTime.now(), + ) + } + } + } + + val now = LocalDateTime.now() + syncedCountsByUserId.forEach { (userId, itemCount) -> + syncStateRepository.save( + JellyfinSyncState( + userId = userId, + lastSyncedAt = now, + lastSuccessfulSyncAt = now, + lastError = null, + syncedItemCount = itemCount, + ), + ) + } + + val summary = + JellyfinSyncSummary( + syncedUsers = syncedCountsByUserId.size, + skippedUsers = skippedUserIds.size, + syncedItems = command.items.size, + durationMs = Duration.between(startedAt, Instant.now()).toMillis(), + ) + businessMetricsService.recordJellyfinSync(summary) + return summary + } + + private fun upsertPluginUsers(command: IngestJellyfinSyncCommand) { + command.users.forEach { remoteUser -> + val jellyfinUserId = + remoteUser.jellyfinUserId + .takeIf { it.isNotBlank() } + ?.let(::normalizeJellyfinId) + ?: return@forEach + if (userRepository.findByJellyfinUserId(jellyfinUserId) != null) { + return@forEach + } + + userRepository.save( + User( + id = idGenerator.generateId(), + name = remoteUser.name?.takeIf { it.isNotBlank() } ?: "Jellyfin User", + email = syntheticJellyfinEmail(jellyfinUserId), + jellyfinUserId = jellyfinUserId, + ), + ) + } + } + + private fun syntheticJellyfinEmail(jellyfinUserId: String): String { + val safeId = + jellyfinUserId + .lowercase(Locale.getDefault()) + .replace(Regex("[^a-z0-9._%+-]"), "-") + .take(240) + return "jellyfin-$safeId@movienight.local" + } + + private fun normalizeJellyfinId(value: String): String = + runCatching { UUID.fromString(value).toString().replace("-", "") } + .getOrDefault(value) } diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index e277aa3..c4248b1 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -97,12 +97,13 @@ info: integrations: jellyfin: - enabled: ${JELLYFIN_SYNC_ENABLED:false} + enabled: ${JELLYFIN_INTEGRATION_ENABLED:false} base-url: ${JELLYFIN_BASE_URL:} web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} api-key: ${JELLYFIN_API_KEY:} sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} + plugin-token: ${JELLYFIN_PLUGIN_TOKEN:} services: user: diff --git a/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt new file mode 100644 index 0000000..d041f0c --- /dev/null +++ b/src/test/kotlin/com/project/movienight/controllers/JellyfinPluginContractTest.kt @@ -0,0 +1,146 @@ +package com.project.movienight.controllers + +import com.fasterxml.jackson.databind.ObjectMapper +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import org.springframework.transaction.annotation.Transactional + +@SpringBootTest( + properties = [ + "integrations.jellyfin.enabled=true", + "integrations.jellyfin.plugin-token=test-token", + "integrations.jellyfin.web-url=https://jellyfin.example.test", + ], +) +@AutoConfigureMockMvc(addFilters = false) +@Transactional +class JellyfinPluginContractTest { + private val jellyfinUserId = "11111111111111111111111111111111" + private val dashedJellyfinUserId = "11111111-1111-1111-1111-111111111111" + private val jellyfinItemId = "22222222222222222222222222222222" + private val dashedJellyfinItemId = "22222222-2222-2222-2222-222222222222" + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var objectMapper: ObjectMapper + + @Test + fun `plugin sync payload creates mapped user and film`() { + postSyncPayload() + + mockMvc + .perform( + get("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendations") + .header("X-MovieNight-Plugin-Token", "test-token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[0].title").value("Jellyfin Contract Film")) + .andExpect(jsonPath("$[0].jellyfinItemId").value(jellyfinItemId)) + .andExpect( + jsonPath("$[0].watchUrl") + .value("https://jellyfin.example.test/web/#/details?id=$jellyfinItemId"), + ) + + mockMvc + .perform( + post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/ratings/items/$dashedJellyfinItemId") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"score":8,"note":"From Jellyfin UI"}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.score").value(8)) + + val viewedPath = + "/api/integrations/jellyfin/users/$dashedJellyfinUserId/library/items/" + + "$dashedJellyfinItemId/viewed" + + mockMvc + .perform( + post(viewedPath) + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"watchedAt":"2026-05-22T10:15:30Z"}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.viewed").value(true)) + + mockMvc + .perform( + get("/api/integrations/jellyfin/sync-state") + .header("X-MovieNight-Plugin-Token", "test-token"), + ).andExpect(status().isOk) + .andExpect(jsonPath("$[0].syncedItemCount").value(1)) + } + + @Test + fun `plugin token is required when configured`() { + mockMvc + .perform( + post("/api/integrations/jellyfin/sync") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(syncPayload())), + ).andExpect(status().isUnauthorized) + } + + @Test + fun `plugin onboarding can create user before first sync`() { + mockMvc + .perform( + post("/api/integrations/jellyfin/users/$dashedJellyfinUserId/recommendation-onboarding") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content("""{"weightedGenres":{"Drama":5},"contentTypes":["FILM"]}"""), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.userId").exists()) + } + + private fun postSyncPayload() { + mockMvc + .perform( + post("/api/integrations/jellyfin/sync") + .header("X-MovieNight-Plugin-Token", "test-token") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(syncPayload())), + ).andExpect(status().isOk) + .andExpect(jsonPath("$.syncedUsers").value(1)) + .andExpect(jsonPath("$.syncedItems").value(1)) + } + + private fun syncPayload(): Map = + mapOf( + "users" to + listOf( + mapOf( + "jellyfinUserId" to jellyfinUserId, + "name" to "Jellyfin User", + ), + ), + "items" to + listOf( + mapOf( + "jellyfinItemId" to jellyfinItemId, + "title" to "Jellyfin Contract Film", + "description" to "Synced from plugin payload", + "year" to 2026, + "genres" to listOf("Drama"), + "imdbId" to "tt1234567", + "userStates" to + listOf( + mapOf( + "jellyfinUserId" to jellyfinUserId, + "isViewed" to false, + "playCount" to 0, + ), + ), + ), + ), + ) +}