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.