230 lines
7.2 KiB
C#
230 lines
7.2 KiB
C#
using System;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Jellyfin.Plugin.Jellypod.Models;
|
|
using MediaBrowser.Controller.Library;
|
|
using MediaBrowser.Controller.Session;
|
|
using Microsoft.Extensions.Hosting;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
|
|
|
/// <summary>
|
|
/// Service that listens to Jellyfin playback events and tracks podcast progress.
|
|
/// </summary>
|
|
public class PlaybackReportingService : IHostedService, IDisposable
|
|
{
|
|
private readonly ISessionManager _sessionManager;
|
|
private readonly IPodcastStorageService _storageService;
|
|
private readonly ILogger<PlaybackReportingService> _logger;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="PlaybackReportingService"/> class.
|
|
/// </summary>
|
|
/// <param name="sessionManager">Session manager.</param>
|
|
/// <param name="storageService">Podcast storage service.</param>
|
|
/// <param name="logger">Logger instance.</param>
|
|
public PlaybackReportingService(
|
|
ISessionManager sessionManager,
|
|
IPodcastStorageService storageService,
|
|
ILogger<PlaybackReportingService> logger)
|
|
{
|
|
_sessionManager = sessionManager;
|
|
_storageService = storageService;
|
|
_logger = logger;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task StartAsync(CancellationToken cancellationToken)
|
|
{
|
|
_sessionManager.PlaybackStart += OnPlaybackStart;
|
|
_sessionManager.PlaybackStopped += OnPlaybackStopped;
|
|
_sessionManager.PlaybackProgress += OnPlaybackProgress;
|
|
|
|
_logger.LogInformation("Jellypod playback reporting service started");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public Task StopAsync(CancellationToken cancellationToken)
|
|
{
|
|
_sessionManager.PlaybackStart -= OnPlaybackStart;
|
|
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
|
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
|
|
|
|
_logger.LogInformation("Jellypod playback reporting service stopped");
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public void Dispose()
|
|
{
|
|
Dispose(true);
|
|
GC.SuppressFinalize(this);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Releases unmanaged and optionally managed resources.
|
|
/// </summary>
|
|
/// <param name="disposing">True to release both managed and unmanaged resources.</param>
|
|
protected virtual void Dispose(bool disposing)
|
|
{
|
|
if (_disposed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (disposing)
|
|
{
|
|
_sessionManager.PlaybackStart -= OnPlaybackStart;
|
|
_sessionManager.PlaybackStopped -= OnPlaybackStopped;
|
|
_sessionManager.PlaybackProgress -= OnPlaybackProgress;
|
|
}
|
|
|
|
_disposed = true;
|
|
}
|
|
|
|
private void OnPlaybackStart(object? sender, PlaybackProgressEventArgs e)
|
|
{
|
|
_ = HandlePlaybackEventAsync(e, "start");
|
|
}
|
|
|
|
private void OnPlaybackProgress(object? sender, PlaybackProgressEventArgs e)
|
|
{
|
|
_ = HandlePlaybackEventAsync(e, "progress");
|
|
}
|
|
|
|
private void OnPlaybackStopped(object? sender, PlaybackStopEventArgs e)
|
|
{
|
|
_ = HandlePlaybackStoppedAsync(e);
|
|
}
|
|
|
|
private async Task HandlePlaybackEventAsync(PlaybackProgressEventArgs e, string eventType)
|
|
{
|
|
try
|
|
{
|
|
var item = e.Item;
|
|
if (item == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Check if this is a channel item (podcast episode)
|
|
var channelId = item.ChannelId;
|
|
if (channelId == Guid.Empty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Try to find the episode by its ID
|
|
var episodeIdStr = item.Id.ToString("N");
|
|
var episode = await FindEpisodeByIdAsync(episodeIdStr).ConfigureAwait(false);
|
|
|
|
if (episode == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
_logger.LogDebug(
|
|
"Playback {EventType} for podcast episode: {Title}, Position: {Position}",
|
|
eventType,
|
|
episode.Episode.Title,
|
|
e.PlaybackPositionTicks);
|
|
|
|
// Update progress
|
|
episode.Episode.PlaybackPositionTicks = e.PlaybackPositionTicks ?? 0;
|
|
episode.Episode.LastPlayedDate = DateTime.UtcNow;
|
|
|
|
await _storageService.UpdatePodcastAsync(episode.Podcast).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error handling playback {EventType} event", eventType);
|
|
}
|
|
}
|
|
|
|
private async Task HandlePlaybackStoppedAsync(PlaybackStopEventArgs e)
|
|
{
|
|
try
|
|
{
|
|
var item = e.Item;
|
|
if (item == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Check if this is a channel item (podcast episode)
|
|
var channelId = item.ChannelId;
|
|
if (channelId == Guid.Empty)
|
|
{
|
|
return;
|
|
}
|
|
|
|
// Try to find the episode by its ID
|
|
var episodeIdStr = item.Id.ToString("N");
|
|
var episode = await FindEpisodeByIdAsync(episodeIdStr).ConfigureAwait(false);
|
|
|
|
if (episode == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
var positionTicks = e.PlaybackPositionTicks ?? 0;
|
|
episode.Episode.PlaybackPositionTicks = positionTicks;
|
|
episode.Episode.LastPlayedDate = DateTime.UtcNow;
|
|
|
|
// Check if episode is complete (95% or more)
|
|
if (episode.Episode.Duration.HasValue && positionTicks > 0)
|
|
{
|
|
var durationTicks = episode.Episode.Duration.Value.Ticks;
|
|
var percentComplete = (double)positionTicks / durationTicks;
|
|
|
|
_logger.LogInformation(
|
|
"Playback stopped for {Title} at {Percent:P1} complete",
|
|
episode.Episode.Title,
|
|
percentComplete);
|
|
|
|
if (percentComplete >= 0.95 && !episode.Episode.IsPlayed)
|
|
{
|
|
episode.Episode.IsPlayed = true;
|
|
episode.Episode.PlayCount++;
|
|
_logger.LogInformation(
|
|
"Episode marked as played: {Title} (played {Count} times)",
|
|
episode.Episode.Title,
|
|
episode.Episode.PlayCount);
|
|
}
|
|
}
|
|
|
|
await _storageService.UpdatePodcastAsync(episode.Podcast).ConfigureAwait(false);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Error handling playback stopped event");
|
|
}
|
|
}
|
|
|
|
private async Task<EpisodeWithPodcast?> FindEpisodeByIdAsync(string episodeId)
|
|
{
|
|
if (!Guid.TryParse(episodeId, out var episodeGuid))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
|
foreach (var podcast in podcasts)
|
|
{
|
|
var episode = podcast.Episodes.FirstOrDefault(e => e.Id == episodeGuid);
|
|
if (episode != null)
|
|
{
|
|
return new EpisodeWithPodcast(podcast, episode);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private sealed record EpisodeWithPodcast(Podcast Podcast, Episode Episode);
|
|
}
|