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; /// /// Service that listens to Jellyfin playback events and tracks podcast progress. /// public class PlaybackReportingService : IHostedService, IDisposable { private readonly ISessionManager _sessionManager; private readonly IPodcastStorageService _storageService; private readonly ILogger _logger; private bool _disposed; /// /// Initializes a new instance of the class. /// /// Session manager. /// Podcast storage service. /// Logger instance. public PlaybackReportingService( ISessionManager sessionManager, IPodcastStorageService storageService, ILogger logger) { _sessionManager = sessionManager; _storageService = storageService; _logger = logger; } /// public Task StartAsync(CancellationToken cancellationToken) { _sessionManager.PlaybackStart += OnPlaybackStart; _sessionManager.PlaybackStopped += OnPlaybackStopped; _sessionManager.PlaybackProgress += OnPlaybackProgress; _logger.LogInformation("Jellypod playback reporting service started"); return Task.CompletedTask; } /// public Task StopAsync(CancellationToken cancellationToken) { _sessionManager.PlaybackStart -= OnPlaybackStart; _sessionManager.PlaybackStopped -= OnPlaybackStopped; _sessionManager.PlaybackProgress -= OnPlaybackProgress; _logger.LogInformation("Jellypod playback reporting service stopped"); return Task.CompletedTask; } /// public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } /// /// Releases unmanaged and optionally managed resources. /// /// True to release both managed and unmanaged resources. 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 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); }