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;
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 */ }
}
@@ -87,9 +87,10 @@ public class MovieNightController : ControllerBase
/// <returns>Backend response.</returns>
[HttpGet("SyncState")]
[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>
@@ -97,14 +98,15 @@ public class MovieNightController : ControllerBase
/// </summary>
[HttpGet("Users/{userId}/Recommendations")]
[Authorize]
public async Task<ActionResult<string>> GetRecommendations(
public async Task<IActionResult> 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");
}
/// <summary>
@@ -142,11 +144,12 @@ public class MovieNightController : ControllerBase
/// </summary>
[HttpGet("Users/{userId}/Preferences")]
[Authorize]
public async Task<ActionResult<string?>> GetPreferences(
public async Task<IActionResult> 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");
}
/// <summary>
@@ -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
/// </summary>
public async Task<string> 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
/// </summary>
public async Task PostRatingAsync(string userId, string filmId, int score, string? note, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/ratings/films/{filmId}");
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
/// </summary>
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 "[]";
using var response = await _httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false);
@@ -140,7 +144,9 @@ public class MovieNightBackendClient
/// </summary>
public async Task MarkViewedAsync(string userId, string filmId, DateTimeOffset? watchedAt, CancellationToken cancellationToken)
{
var request = CreateRequest(HttpMethod.Post, $"/api/users/{userId}/library/films/{filmId}/viewed");
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
/// </summary>
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;
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
/// </summary>
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;
@@ -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<object>();
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<string, object?>
{
["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");
}
}
+31 -1
View File
@@ -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=<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:
@@ -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.
@@ -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()
@@ -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 {}",
@@ -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
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<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 {
fun syncNow(): JellyfinSyncSummary
fun ingest(command: IngestJellyfinSyncCommand): JellyfinSyncSummary
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 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)
}
@@ -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<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 {
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<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:
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:
@@ -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,
),
),
),
),
)
}