fix(jellyfin): fixes in jellyfin integration

This commit is contained in:
ITQ
2026-05-22 18:04:28 +03:00
parent 5b9b4ce03a
commit 92add56cf7
16 changed files with 785 additions and 67 deletions
@@ -398,8 +398,14 @@
if (!statusEl) return; if (!statusEl) return;
try { try {
const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`)); const state = await ApiClient.getJSON(ApiClient.getUrl(`MovieNight/SyncState`));
if (state && state.lastSyncAt) { const states = Array.isArray(state) ? state : [];
statusEl.innerText = `Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`; 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 */ } } catch (err) { /* ignore */ }
} }
@@ -87,9 +87,10 @@ public class MovieNightController : ControllerBase
/// <returns>Backend response.</returns> /// <returns>Backend response.</returns>
[HttpGet("SyncState")] [HttpGet("SyncState")]
[Authorize] [Authorize]
public async Task<ActionResult<string>> SyncState(CancellationToken cancellationToken) public async Task<IActionResult> SyncState(CancellationToken cancellationToken)
{ {
return await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false); var body = await _backendClient.GetSyncStateAsync(cancellationToken).ConfigureAwait(false);
return Content(body, "application/json");
} }
/// <summary> /// <summary>
@@ -97,14 +98,15 @@ public class MovieNightController : ControllerBase
/// </summary> /// </summary>
[HttpGet("Users/{userId}/Recommendations")] [HttpGet("Users/{userId}/Recommendations")]
[Authorize] [Authorize]
public async Task<ActionResult<string>> GetRecommendations( public async Task<IActionResult> GetRecommendations(
[FromRoute] string userId, [FromRoute] string userId,
[FromQuery] string? contentType, [FromQuery] string? contentType,
[FromQuery] string? mood, [FromQuery] string? mood,
[FromQuery] int limit = 10, [FromQuery] int limit = 10,
CancellationToken cancellationToken = default) 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");
} }
/// <summary> /// <summary>
@@ -142,11 +144,12 @@ public class MovieNightController : ControllerBase
/// </summary> /// </summary>
[HttpGet("Users/{userId}/Preferences")] [HttpGet("Users/{userId}/Preferences")]
[Authorize] [Authorize]
public async Task<ActionResult<string?>> GetPreferences( public async Task<IActionResult> GetPreferences(
[FromRoute] string userId, [FromRoute] string userId,
CancellationToken cancellationToken) 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");
} }
/// <summary> /// <summary>
@@ -40,7 +40,7 @@ public class MovieNightBackendClient
{ {
var payload = new MovieNightEventPayload( var payload = new MovieNightEventPayload(
EventId: $"plugin-test:{Guid.NewGuid():N}", EventId: $"plugin-test:{Guid.NewGuid():N}",
EventType: "playback.stopped", EventType: "plugin.test",
OccurredAt: DateTimeOffset.UtcNow, OccurredAt: DateTimeOffset.UtcNow,
JellyfinUserId: "movienight-plugin-test-user", JellyfinUserId: "movienight-plugin-test-user",
ItemId: "movienight-plugin-test-item", ItemId: "movienight-plugin-test-item",
@@ -95,11 +95,12 @@ public class MovieNightBackendClient
/// </summary> /// </summary>
public async Task<string> GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken) public async Task<string> GetRecommendationsAsync(string userId, string? contentType, string? mood, int limit, CancellationToken cancellationToken)
{ {
userId = NormalizeJellyfinId(userId);
var query = $"?limit={limit}"; var query = $"?limit={limit}";
if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}"; if (!string.IsNullOrEmpty(contentType)) query += $"&contentType={Uri.EscapeDataString(contentType)}";
if (!string.IsNullOrEmpty(mood)) query += $"&mood={Uri.EscapeDataString(mood)}"; 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."; if (request is null) return "Plugin is not configured.";
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
@@ -113,7 +114,9 @@ public class MovieNightBackendClient
/// </summary> /// </summary>
public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken) 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; if (request is null) return;
request.Content = JsonContent.Create(new { score, note }, options: JsonOptions); request.Content = JsonContent.Create(new { score, note }, options: JsonOptions);
@@ -126,7 +129,8 @@ public class MovieNightBackendClient
/// </summary> /// </summary>
public async Task<string> GetRatingsAsync(string userId, CancellationToken cancellationToken) public async Task<string> 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 "[]"; if (request is null) return "[]";
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
@@ -140,7 +144,9 @@ public class MovieNightBackendClient
/// </summary> /// </summary>
public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken) 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; if (request is null) return;
request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions); request.Content = JsonContent.Create(new { watchedAt }, options: JsonOptions);
@@ -172,13 +178,15 @@ public class MovieNightBackendClient
/// </summary> /// </summary>
public async Task<string?> GetPreferencesAsync(string userId, CancellationToken cancellationToken) public async Task<string?> 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; if (request is null) return null;
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; if (response.StatusCode == System.Net.HttpStatusCode.NotFound) return null;
var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
return body; return body;
} }
@@ -187,7 +195,8 @@ public class MovieNightBackendClient
/// </summary> /// </summary>
public async Task CompleteOnboardingAsync(string userId, object payload, CancellationToken cancellationToken) 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; if (request is null) return;
request.Content = JsonContent.Create(payload, options: JsonOptions); request.Content = JsonContent.Create(payload, options: JsonOptions);
@@ -257,6 +266,11 @@ public class MovieNightBackendClient
return string.IsNullOrWhiteSpace(value) ? null : value.TrimEnd('/'); 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() private static bool IsEnabled()
{ {
var configuration = Plugin.Instance?.Configuration; var configuration = Plugin.Instance?.Configuration;
@@ -64,6 +64,11 @@ public class MovieNightSyncService
var items = _libraryManager.GetItemList(query); var items = _libraryManager.GetItemList(query);
var users = _userManager.Users; var users = _userManager.Users;
var syncUsers = users.Select(u => new
{
jellyfinUserId = u.Id.ToString("N"),
name = u.Username
}).ToList();
var syncItems = new List<object>(); var syncItems = new List<object>();
foreach (var item in items) foreach (var item in items)
@@ -71,11 +76,12 @@ public class MovieNightSyncService
if (item is not Movie movie) continue; if (item is not Movie movie) continue;
var jellyfinItemId = movie.Id.ToString("N"); var jellyfinItemId = movie.Id.ToString("N");
var title = string.IsNullOrWhiteSpace(movie.Name) ? jellyfinItemId : movie.Name;
var itemData = new Dictionary<string, object?> var itemData = new Dictionary<string, object?>
{ {
["jellyfinItemId"] = jellyfinItemId, ["jellyfinItemId"] = jellyfinItemId,
["title"] = movie.Name, ["title"] = title,
["originalTitle"] = movie.OriginalTitle, ["originalTitle"] = movie.OriginalTitle,
["description"] = movie.Overview, ["description"] = movie.Overview,
["year"] = movie.ProductionYear, ["year"] = movie.ProductionYear,
@@ -99,7 +105,7 @@ public class MovieNightSyncService
syncItems.Add(itemData); 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"); _logger.LogInformation("MovieNight library sync completed");
} }
} }
+31 -1
View File
@@ -18,6 +18,34 @@ Current implemented calls:
- `POST /api/integrations/jellyfin/sync` - `POST /api/integrations/jellyfin/sync`
- `GET /api/integrations/jellyfin/sync-state` - `GET /api/integrations/jellyfin/sync-state`
- `POST /api/integrations/jellyfin/events` - `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=<same token configured in the plugin>`
- `JELLYFIN_WEB_URL=<browser URL of Jellyfin, used for recommendation watch links>`
`JELLYFIN_SYNC_ENABLED=true` is still accepted as a legacy alias for `JELLYFIN_INTEGRATION_ENABLED=true`.
Optional backend-pull sync values:
- `JELLYFIN_BASE_URL=<backend-reachable Jellyfin server URL>`
- `JELLYFIN_API_KEY=<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: 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 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.
@@ -24,9 +24,7 @@ class SecurityConfiguration(
.requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health") .requestMatchers("/", "/login/**", "/oauth2/**", "/h2-console/**", "/actuator/health")
.permitAll() .permitAll()
.requestMatchers( .requestMatchers(
"/api/integrations/jellyfin/events", "/api/integrations/jellyfin/**",
"/api/integrations/jellyfin/sync",
"/api/integrations/jellyfin/sync-state",
).permitAll() ).permitAll()
.requestMatchers("/api/users/me") .requestMatchers("/api/users/me")
.authenticated() .authenticated()
@@ -3,7 +3,6 @@ package com.project.movienight.adapters.web
import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest import com.project.movienight.adapters.web.dto.request.JellyfinEventRequest
import com.project.movienight.application.ports.input.HandleJellyfinEventCommand import com.project.movienight.application.ports.input.HandleJellyfinEventCommand
import com.project.movienight.application.ports.input.JellyfinEventUseCase import com.project.movienight.application.ports.input.JellyfinEventUseCase
import com.project.movienight.config.JellyfinIntegrationProperties
import jakarta.validation.Valid import jakarta.validation.Valid
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import org.springframework.http.HttpStatus 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.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
import org.springframework.web.server.ResponseStatusException
@RestController @RestController
@RequestMapping("/api/integrations/jellyfin") @RequestMapping("/api/integrations/jellyfin")
class JellyfinEventsController( class JellyfinEventsController(
private val jellyfinEventUseCase: JellyfinEventUseCase, private val jellyfinEventUseCase: JellyfinEventUseCase,
private val properties: JellyfinIntegrationProperties, private val authenticator: JellyfinPluginAuthenticator,
) { ) {
private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java) private val log = LoggerFactory.getLogger(JellyfinEventsController::class.java)
@@ -29,15 +27,7 @@ class JellyfinEventsController(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?, @RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
@Valid @RequestBody request: JellyfinEventRequest, @Valid @RequestBody request: JellyfinEventRequest,
) { ) {
if (!properties.enabled) { authenticator.authenticate(token)
throw ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Jellyfin integration is disabled")
}
if (properties.pluginToken.isNotBlank()) {
if (token == null || token != properties.pluginToken) {
throw ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid plugin token")
}
}
log.debug( log.debug(
"Received Jellyfin event {} for user {} item {}", "Received Jellyfin event {} for user {} item {}",
@@ -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")
}
}
}
@@ -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<RecommendationResponse> {
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<FilmRatingResponse> {
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,
)
@@ -1,10 +1,18 @@
package com.project.movienight.adapters.web 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.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.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.JellyfinSyncSummary
import jakarta.validation.Valid
import org.springframework.web.bind.annotation.GetMapping import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PostMapping 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.RequestMapping
import org.springframework.web.bind.annotation.RestController import org.springframework.web.bind.annotation.RestController
@@ -12,10 +20,61 @@ import org.springframework.web.bind.annotation.RestController
@RequestMapping("/api/integrations/jellyfin") @RequestMapping("/api/integrations/jellyfin")
class JellyfinSyncController( class JellyfinSyncController(
private val jellyfinSyncUseCase: JellyfinSyncUseCase, private val jellyfinSyncUseCase: JellyfinSyncUseCase,
private val authenticator: JellyfinPluginAuthenticator,
) { ) {
@PostMapping("/sync") @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") @GetMapping("/sync-state")
fun syncState(): List<JellyfinSyncState> = jellyfinSyncUseCase.getSyncStates() fun syncState(
@RequestHeader(value = "X-MovieNight-Plugin-Token", required = false) token: String?,
): List<JellyfinSyncState> {
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,
)
},
)
},
)
} }
@@ -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<JellyfinSyncUserRequest> = emptyList(),
@field:Valid
val items: List<JellyfinSyncItemRequest> = 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<String> = emptyList(),
val imdbId: String? = null,
val tmdbId: String? = null,
val jellyfinLibraryId: String? = null,
@field:Valid
val userStates: List<JellyfinSyncUserStateRequest> = 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,
)
@@ -21,5 +21,38 @@ data class HandleJellyfinEventCommand(
interface JellyfinSyncUseCase { interface JellyfinSyncUseCase {
fun syncNow(): JellyfinSyncSummary fun syncNow(): JellyfinSyncSummary
fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary
fun getSyncStates(): List<JellyfinSyncState> fun getSyncStates(): List<JellyfinSyncState>
} }
data class IngestJellyfinSyncCommand(
val users: List<JellyfinSyncUserCommand> = emptyList(),
val items: List<JellyfinSyncItemCommand> = 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<String>,
val imdbId: String?,
val tmdbId: String?,
val jellyfinLibraryId: String?,
val userStates: List<JellyfinSyncUserStateCommand>,
)
data class JellyfinSyncUserStateCommand(
val jellyfinUserId: String,
val isViewed: Boolean,
val playCount: Int,
val lastPlayedAt: OffsetDateTime?,
val userRating: Double?,
)
@@ -12,6 +12,7 @@ import com.project.movienight.application.ports.output.JellyfinEventStorePort
import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.util.UUID
@Service @Service
class JellyfinEventService( class JellyfinEventService(
@@ -26,6 +27,8 @@ class JellyfinEventService(
@Transactional @Transactional
override fun handle(command: HandleJellyfinEventCommand) { override fun handle(command: HandleJellyfinEventCommand) {
val jellyfinUserId = normalizeJellyfinId(command.jellyfinUserId)
val jellyfinItemId = normalizeJellyfinId(command.itemId)
val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) } val payloadJson = command.payload?.let { objectMapper.writeValueAsString(it) }
val inserted = val inserted =
jellyfinEventStore.save( jellyfinEventStore.save(
@@ -34,8 +37,8 @@ class JellyfinEventService(
serverId = command.serverId, serverId = command.serverId,
eventType = command.eventType, eventType = command.eventType,
occurredAt = command.occurredAt, occurredAt = command.occurredAt,
jellyfinUserId = command.jellyfinUserId, jellyfinUserId = jellyfinUserId,
jellyfinItemId = command.itemId, jellyfinItemId = jellyfinItemId,
payload = payloadJson, payload = payloadJson,
), ),
) )
@@ -45,13 +48,13 @@ class JellyfinEventService(
try { try {
if (playbackEventTypes.contains(command.eventType)) { if (playbackEventTypes.contains(command.eventType)) {
val localUser = userRepository.findByJellyfinUserId(command.jellyfinUserId) val localUser = userRepository.findByJellyfinUserId(jellyfinUserId)
if (localUser == null) { if (localUser == null) {
businessMetricsService.recordJellyfinUnmappedUser() businessMetricsService.recordJellyfinUnmappedUser()
return return
} }
val film = filmRepository.findByJellyfinItemId(command.itemId) val film = filmRepository.findByJellyfinItemId(jellyfinItemId)
if (film == null) { if (film == null) {
businessMetricsService.recordBackendWriteFailure() businessMetricsService.recordBackendWriteFailure()
return return
@@ -72,4 +75,8 @@ class JellyfinEventService(
throw ex throw ex
} }
} }
private fun normalizeJellyfinId(value: String): String =
runCatching { UUID.fromString(value).toString().replace("-", "") }
.getOrDefault(value)
} }
@@ -1,5 +1,6 @@
package com.project.movienight.application.services 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.input.JellyfinSyncUseCase
import com.project.movienight.application.ports.output.BusinessMetricsPort import com.project.movienight.application.ports.output.BusinessMetricsPort
import com.project.movienight.application.ports.output.FilmLibraryEntryRepositoryPort 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.JellyfinSyncStateRepositoryPort
import com.project.movienight.application.ports.output.UserRepositoryPort import com.project.movienight.application.ports.output.UserRepositoryPort
import com.project.movienight.config.JellyfinIntegrationProperties 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.Film
import com.project.movienight.domain.model.FilmLibraryEntry import com.project.movienight.domain.model.FilmLibraryEntry
import com.project.movienight.domain.model.JellyfinSyncState import com.project.movienight.domain.model.JellyfinSyncState
import com.project.movienight.domain.model.JellyfinSyncSummary import com.project.movienight.domain.model.JellyfinSyncSummary
import com.project.movienight.domain.model.User
import org.springframework.scheduling.annotation.Scheduled import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.time.Duration import java.time.Duration
import java.time.Instant import java.time.Instant
import java.time.LocalDateTime import java.time.LocalDateTime
import java.util.Locale
import java.util.UUID import java.util.UUID
@Service @Service
@@ -56,6 +60,21 @@ class JellyfinSyncService(
override fun getSyncStates(): List<JellyfinSyncState> = syncStateRepository.findAll() override fun getSyncStates(): List<JellyfinSyncState> = 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 { private fun runSync(): JellyfinSyncSummary {
val startedAt = Instant.now() val startedAt = Instant.now()
val remoteUsers = jellyfinCatalog.fetchUsers() val remoteUsers = jellyfinCatalog.fetchUsers()
@@ -63,7 +82,7 @@ class JellyfinSyncService(
userRepository userRepository
.findAll() .findAll()
.mapNotNull { user -> .mapNotNull { user ->
user.jellyfinUserId?.let { it to user } user.jellyfinUserId?.let { normalizeJellyfinId(it) to user }
}.toMap() }.toMap()
var syncedUsers = 0 var syncedUsers = 0
@@ -71,7 +90,7 @@ class JellyfinSyncService(
var syncedItems = 0 var syncedItems = 0
remoteUsers.forEach { remoteUser -> remoteUsers.forEach { remoteUser ->
val localUser = localUsersByJellyfinId[remoteUser.id] val localUser = localUsersByJellyfinId[normalizeJellyfinId(remoteUser.id)]
if (localUser == null) { if (localUser == null) {
skippedUsers += 1 skippedUsers += 1
return@forEach return@forEach
@@ -123,34 +142,39 @@ class JellyfinSyncService(
} }
private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film { private fun upsertFilm(item: JellyfinLibraryItemSnapshot): Film {
val normalizedItem =
item.copy(
jellyfinItemId = normalizeJellyfinId(item.jellyfinItemId),
jellyfinLibraryId = item.jellyfinLibraryId?.let(::normalizeJellyfinId),
)
val film = val film =
filmRepository.findByJellyfinItemId(item.jellyfinItemId)?.copy( filmRepository.findByJellyfinItemId(normalizedItem.jellyfinItemId)?.copy(
title = item.title, title = normalizedItem.title,
description = item.description, description = normalizedItem.description,
contentType = item.contentType, contentType = normalizedItem.contentType,
releaseYear = item.releaseYear, releaseYear = normalizedItem.releaseYear,
genres = item.genres, genres = normalizedItem.genres,
cast = item.cast, cast = normalizedItem.cast,
directors = item.directors, directors = normalizedItem.directors,
imdbRating = item.imdbRating, imdbRating = normalizedItem.imdbRating,
platformRating = item.platformRating, platformRating = normalizedItem.platformRating,
externalUrl = item.externalUrl, externalUrl = normalizedItem.externalUrl,
jellyfinItemId = item.jellyfinItemId, jellyfinItemId = normalizedItem.jellyfinItemId,
jellyfinLibraryId = item.jellyfinLibraryId, jellyfinLibraryId = normalizedItem.jellyfinLibraryId,
) ?: Film( ) ?: Film(
id = idGenerator.generateId(), id = idGenerator.generateId(),
title = item.title, title = normalizedItem.title,
description = item.description, description = normalizedItem.description,
contentType = item.contentType, contentType = normalizedItem.contentType,
releaseYear = item.releaseYear, releaseYear = normalizedItem.releaseYear,
genres = item.genres, genres = normalizedItem.genres,
cast = item.cast, cast = normalizedItem.cast,
directors = item.directors, directors = normalizedItem.directors,
imdbRating = item.imdbRating, imdbRating = normalizedItem.imdbRating,
platformRating = item.platformRating, platformRating = normalizedItem.platformRating,
externalUrl = item.externalUrl, externalUrl = normalizedItem.externalUrl,
jellyfinItemId = item.jellyfinItemId, jellyfinItemId = normalizedItem.jellyfinItemId,
jellyfinLibraryId = item.jellyfinLibraryId, jellyfinLibraryId = normalizedItem.jellyfinLibraryId,
) )
return filmRepository.save(film) 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<String>()
val syncedCountsByUserId = mutableMapOf<UUID, Int>()
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)
} }
+2 -1
View File
@@ -97,12 +97,13 @@ info:
integrations: integrations:
jellyfin: jellyfin:
enabled: ${JELLYFIN_SYNC_ENABLED:false} enabled: ${JELLYFIN_INTEGRATION_ENABLED:false}
base-url: ${JELLYFIN_BASE_URL:} base-url: ${JELLYFIN_BASE_URL:}
web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}} web-url: ${JELLYFIN_WEB_URL:${JELLYFIN_BASE_URL:}}
api-key: ${JELLYFIN_API_KEY:} api-key: ${JELLYFIN_API_KEY:}
sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000} sync-interval-ms: ${JELLYFIN_SYNC_INTERVAL_MS:1800000}
request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000} request-timeout-ms: ${JELLYFIN_REQUEST_TIMEOUT_MS:20000}
plugin-token: ${JELLYFIN_PLUGIN_TOKEN:}
services: services:
user: user:
@@ -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<String, Any?> =
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,
),
),
),
),
)
}