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);
+}