fix(jellyfin): fixes in jellyfin integration
This commit is contained in:
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+43
@@ -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?,
|
||||
)
|
||||
|
||||
+11
-4
@@ -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)
|
||||
}
|
||||
|
||||
+161
-27
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user