Add progress and complettion indication
All checks were successful
🏗️ Build Plugin / build (push) Successful in 1m59s
🧪 Test Plugin / test (push) Successful in 56s
🚀 Release Plugin / build-and-release (push) Successful in 1m56s

This commit is contained in:
Duncan Tourolle 2025-12-14 11:11:26 +01:00
parent ba497924e9
commit c1f7981ed7
7 changed files with 421 additions and 2 deletions

View File

@ -362,4 +362,116 @@ public class JellypodController : ControllerBase
return Ok(new { RemovedCount = duplicatesToRemove.Count }); return Ok(new { RemovedCount = duplicatesToRemove.Count });
} }
/// <summary>
/// Updates playback progress for an episode.
/// </summary>
/// <param name="podcastId">Podcast ID.</param>
/// <param name="episodeId">Episode ID.</param>
/// <param name="request">Progress update request.</param>
/// <returns>No content.</returns>
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/progress")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> 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();
}
/// <summary>
/// Marks an episode as played or unplayed.
/// </summary>
/// <param name="podcastId">Podcast ID.</param>
/// <param name="episodeId">Episode ID.</param>
/// <param name="played">Whether the episode is played.</param>
/// <returns>No content.</returns>
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/played")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> 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();
}
/// <summary>
/// Gets playback progress for an episode.
/// </summary>
/// <param name="podcastId">Podcast ID.</param>
/// <param name="episodeId">Episode ID.</param>
/// <returns>Playback progress info.</returns>
[HttpGet("podcasts/{podcastId}/episodes/{episodeId}/progress")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<PlaybackProgressResponse>> 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
});
}
} }

View File

@ -0,0 +1,12 @@
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Request to update playback progress.
/// </summary>
public class PlaybackProgressRequest
{
/// <summary>
/// Gets or sets the playback position in ticks.
/// </summary>
public long PositionTicks { get; set; }
}

View File

@ -0,0 +1,29 @@
using System;
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Response containing playback progress info.
/// </summary>
public class PlaybackProgressResponse
{
/// <summary>
/// Gets or sets the playback position in ticks.
/// </summary>
public long PositionTicks { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the episode has been played.
/// </summary>
public bool IsPlayed { get; set; }
/// <summary>
/// Gets or sets the date the episode was last played.
/// </summary>
public DateTime? LastPlayedDate { get; set; }
/// <summary>
/// Gets or sets the number of times the episode has been played.
/// </summary>
public int PlayCount { get; set; }
}

View File

@ -236,13 +236,27 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
foreach (var episode in episodes) 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 // Don't provide MediaSources here - this forces Jellyfin to call GetChannelItemMediaInfo
// which allows us to download-on-demand and return proper local file paths // which allows us to download-on-demand and return proper local file paths
items.Add(new ChannelItemInfo items.Add(new ChannelItemInfo
{ {
Id = episode.Id.ToString("N"), Id = episode.Id.ToString("N"),
Name = episode.Title, Name = episodeName,
Overview = episode.Description, Overview = overview,
ImageUrl = episode.ImageUrl ?? podcast.ImageUrl, ImageUrl = episode.ImageUrl ?? podcast.ImageUrl,
Type = ChannelItemType.Media, Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Podcast, ContentType = ChannelMediaContentType.Podcast,

View File

@ -81,4 +81,24 @@ public class Episode
/// Gets or sets the episode-specific image URL. /// Gets or sets the episode-specific image URL.
/// </summary> /// </summary>
public string? ImageUrl { get; set; } public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets the playback position in ticks.
/// </summary>
public long PlaybackPositionTicks { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the episode has been played/completed.
/// </summary>
public bool IsPlayed { get; set; }
/// <summary>
/// Gets or sets the date the episode was last played.
/// </summary>
public DateTime? LastPlayedDate { get; set; }
/// <summary>
/// Gets or sets the number of times the episode has been played.
/// </summary>
public int PlayCount { get; set; }
} }

View File

@ -29,5 +29,8 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
// Register channel // Register channel
serviceCollection.AddSingleton<IChannel, JellypodChannel>(); serviceCollection.AddSingleton<IChannel, JellypodChannel>();
// Register playback reporting service
serviceCollection.AddHostedService<PlaybackReportingService>();
} }
} }

View File

@ -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;
/// <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);
}