From c1f7981ed73ab138d3b438cda8c107e3d5fe197c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 14 Dec 2025 11:11:26 +0100 Subject: [PATCH] Add progress and complettion indication --- .../Api/JellypodController.cs | 112 +++++++++ .../Api/Models/PlaybackProgressRequest.cs | 12 + .../Api/Models/PlaybackProgressResponse.cs | 29 +++ .../Channels/JellypodChannel.cs | 18 +- Jellyfin.Plugin.Jellypod/Models/Episode.cs | 20 ++ .../PluginServiceRegistrator.cs | 3 + .../Services/PlaybackReportingService.cs | 229 ++++++++++++++++++ 7 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressRequest.cs create mode 100644 Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressResponse.cs create mode 100644 Jellyfin.Plugin.Jellypod/Services/PlaybackReportingService.cs diff --git a/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs index 4baa6dc..93dd4e4 100644 --- a/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs +++ b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs @@ -362,4 +362,116 @@ public class JellypodController : ControllerBase return Ok(new { RemovedCount = duplicatesToRemove.Count }); } + + /// + /// Updates playback progress for an episode. + /// + /// Podcast ID. + /// Episode ID. + /// Progress update request. + /// No content. + [HttpPost("podcasts/{podcastId}/episodes/{episodeId}/progress")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task UpdatePlaybackProgress( + [FromRoute] Guid podcastId, + [FromRoute] Guid episodeId, + [FromBody] PlaybackProgressRequest request) + { + var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false); + var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId); + + if (podcast == null || episode == null) + { + return NotFound(); + } + + episode.PlaybackPositionTicks = request.PositionTicks; + episode.LastPlayedDate = DateTime.UtcNow; + + // Mark as played if we've reached near the end (within last 5%) + if (episode.Duration.HasValue && request.PositionTicks > 0) + { + var durationTicks = episode.Duration.Value.Ticks; + var percentComplete = (double)request.PositionTicks / durationTicks; + if (percentComplete >= 0.95) + { + episode.IsPlayed = true; + episode.PlayCount++; + _logger.LogInformation("Episode marked as played: {Title} (played {Count} times)", episode.Title, episode.PlayCount); + } + } + + await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false); + return NoContent(); + } + + /// + /// Marks an episode as played or unplayed. + /// + /// Podcast ID. + /// Episode ID. + /// Whether the episode is played. + /// No content. + [HttpPost("podcasts/{podcastId}/episodes/{episodeId}/played")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task SetPlayedStatus( + [FromRoute] Guid podcastId, + [FromRoute] Guid episodeId, + [FromQuery] bool played = true) + { + var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false); + var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId); + + if (podcast == null || episode == null) + { + return NotFound(); + } + + episode.IsPlayed = played; + if (played) + { + episode.LastPlayedDate = DateTime.UtcNow; + episode.PlayCount++; + } + else + { + episode.PlaybackPositionTicks = 0; + } + + await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false); + _logger.LogInformation("Episode {Title} marked as {Status}", episode.Title, played ? "played" : "unplayed"); + return NoContent(); + } + + /// + /// Gets playback progress for an episode. + /// + /// Podcast ID. + /// Episode ID. + /// Playback progress info. + [HttpGet("podcasts/{podcastId}/episodes/{episodeId}/progress")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPlaybackProgress( + [FromRoute] Guid podcastId, + [FromRoute] Guid episodeId) + { + var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false); + var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId); + + if (podcast == null || episode == null) + { + return NotFound(); + } + + return Ok(new PlaybackProgressResponse + { + PositionTicks = episode.PlaybackPositionTicks, + IsPlayed = episode.IsPlayed, + LastPlayedDate = episode.LastPlayedDate, + PlayCount = episode.PlayCount + }); + } } diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressRequest.cs b/Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressRequest.cs new file mode 100644 index 0000000..ace6c33 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressRequest.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Request to update playback progress. +/// +public class PlaybackProgressRequest +{ + /// + /// Gets or sets the playback position in ticks. + /// + public long PositionTicks { get; set; } +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressResponse.cs b/Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressResponse.cs new file mode 100644 index 0000000..386e346 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/PlaybackProgressResponse.cs @@ -0,0 +1,29 @@ +using System; + +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Response containing playback progress info. +/// +public class PlaybackProgressResponse +{ + /// + /// Gets or sets the playback position in ticks. + /// + public long PositionTicks { get; set; } + + /// + /// Gets or sets a value indicating whether the episode has been played. + /// + public bool IsPlayed { get; set; } + + /// + /// Gets or sets the date the episode was last played. + /// + public DateTime? LastPlayedDate { get; set; } + + /// + /// Gets or sets the number of times the episode has been played. + /// + public int PlayCount { get; set; } +} diff --git a/Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs b/Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs index 2e10153..eff9018 100644 --- a/Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs +++ b/Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs @@ -236,13 +236,27 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac foreach (var episode in episodes) { + // Build episode name with played indicator + var episodeName = episode.IsPlayed + ? $"[Played] {episode.Title}" + : episode.Title; + + // Build overview with progress info if partially played + var overview = episode.Description ?? string.Empty; + if (episode.PlaybackPositionTicks > 0 && !episode.IsPlayed && episode.Duration.HasValue) + { + var progressPercent = (int)((double)episode.PlaybackPositionTicks / episode.Duration.Value.Ticks * 100); + var positionTime = TimeSpan.FromTicks(episode.PlaybackPositionTicks); + overview = $"[{progressPercent}% - {positionTime:hh\\:mm\\:ss}] {overview}"; + } + // Don't provide MediaSources here - this forces Jellyfin to call GetChannelItemMediaInfo // which allows us to download-on-demand and return proper local file paths items.Add(new ChannelItemInfo { Id = episode.Id.ToString("N"), - Name = episode.Title, - Overview = episode.Description, + Name = episodeName, + Overview = overview, ImageUrl = episode.ImageUrl ?? podcast.ImageUrl, Type = ChannelItemType.Media, ContentType = ChannelMediaContentType.Podcast, diff --git a/Jellyfin.Plugin.Jellypod/Models/Episode.cs b/Jellyfin.Plugin.Jellypod/Models/Episode.cs index b51bca9..d74571a 100644 --- a/Jellyfin.Plugin.Jellypod/Models/Episode.cs +++ b/Jellyfin.Plugin.Jellypod/Models/Episode.cs @@ -81,4 +81,24 @@ public class Episode /// Gets or sets the episode-specific image URL. /// public string? ImageUrl { get; set; } + + /// + /// Gets or sets the playback position in ticks. + /// + public long PlaybackPositionTicks { get; set; } + + /// + /// Gets or sets a value indicating whether the episode has been played/completed. + /// + public bool IsPlayed { get; set; } + + /// + /// Gets or sets the date the episode was last played. + /// + public DateTime? LastPlayedDate { get; set; } + + /// + /// Gets or sets the number of times the episode has been played. + /// + public int PlayCount { get; set; } } diff --git a/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs b/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs index 07478de..05a3d73 100644 --- a/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs +++ b/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs @@ -29,5 +29,8 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator // Register channel serviceCollection.AddSingleton(); + + // Register playback reporting service + serviceCollection.AddHostedService(); } } diff --git a/Jellyfin.Plugin.Jellypod/Services/PlaybackReportingService.cs b/Jellyfin.Plugin.Jellypod/Services/PlaybackReportingService.cs new file mode 100644 index 0000000..dcb69d2 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/PlaybackReportingService.cs @@ -0,0 +1,229 @@ +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); +}