refactor: обновить use case слои и интеграцию Jellyfin

Основные изменения: укрупнены use case-интерфейсы, контроллеры переведены на цельные зависимости, логика доступных фильмов перенесена в FilmLibraryService, добавлены проверки существования фильма и улучшена обработка ошибок API.

Метрики: FilmService больше не зависит напрямую от Micrometer для counters, используется BusinessMetricsPort; добавлены TimedAspect и duration-метрики через @Timed для create/edit/delete фильмов.

Jellyfin: event handling переведен на транзакционную модель, sync учитывает runtime-ошибки, добавлены plugin-token/web-url настройки, webhook и sync endpoints защищены X-MovieNight-Plugin-Token.

Плагин: добавлен Jellyfin plugin в plugins/jellyfin, backend принимает push-sync payload, sync-state доступен плагину, pull-sync вынесен в /api/integrations/jellyfin/pull-sync, README описывает фактический контракт.

API и DTO: добавлены RecommendationResponse, ContentTypeParser, validation annotations, обработка validation errors и ResponseStatusException, endpoint /api/users/me.

БД: добавлена V7 cleanup-миграция для legacy ratings/jellyfin_id объектов; V4/V5 в этот коммит не включались.

Проверка: .\gradlew.bat check --stacktrace проходит полностью.
This commit is contained in:
skettiks
2026-05-22 12:32:16 +03:00
parent f4bccf4bf0
commit 43be102d4e
86 changed files with 2920 additions and 1004 deletions
@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.MovieNight.Services;
/// <summary>
/// Subscribes to Jellyfin playback events and forwards thin payloads.
/// </summary>
public sealed class MovieNightPlaybackEventService : IHostedService
{
private readonly ISessionManager _sessionManager;
private readonly MovieNightBackendClient _backendClient;
private readonly ILogger<MovieNightPlaybackEventService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="MovieNightPlaybackEventService"/> class.
/// </summary>
/// <param name="sessionManager">Jellyfin session manager.</param>
/// <param name="backendClient">Backend client.</param>
/// <param name="logger">Logger.</param>
public MovieNightPlaybackEventService(
ISessionManager sessionManager,
MovieNightBackendClient backendClient,
ILogger<MovieNightPlaybackEventService> logger)
{
_sessionManager = sessionManager;
_backendClient = backendClient;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped += OnPlaybackStopped;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
return Task.CompletedTask;
}
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
{
if (Plugin.Instance?.Configuration is not { Enabled: true, EnablePlaybackEvents: true })
{
return;
}
if (!e.PlayedToCompletion)
{
return;
}
var userId = e.Users?.FirstOrDefault()?.Id.ToString("N");
var itemId = e.Item?.Id.ToString("N");
if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(itemId))
{
return;
}
var occurredAt = DateTimeOffset.UtcNow;
var eventId = string.IsNullOrWhiteSpace(e.PlaySessionId)
? $"playback-stopped:{userId}:{itemId}:{occurredAt.ToUnixTimeMilliseconds()}"
: $"playback-stopped:{userId}:{itemId}:{e.PlaySessionId}";
var payload = new MovieNightEventPayload(
EventId: eventId,
EventType: "playback.stopped",
OccurredAt: occurredAt,
JellyfinUserId: userId,
ItemId: itemId,
PayloadVersion: 1,
Payload: new Dictionary<string, object?>
{
["itemName"] = e.Item?.Name,
["playSessionId"] = e.PlaySessionId,
["positionTicks"] = e.PlaybackPositionTicks,
["playedToCompletion"] = e.PlayedToCompletion
});
_ = Task.Run(
async () =>
{
try
{
await _backendClient.PushEventAsync(payload, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "MovieNight playback event push failed");
}
});
}
}