Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4275837bc | ||
|
|
c1f7981ed7 |
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -4,42 +4,64 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<title>Jellypod</title>
|
<title>Jellypod</title>
|
||||||
<style>
|
<style>
|
||||||
.podcast-item {
|
.podcast-table {
|
||||||
display: flex;
|
width: 100%;
|
||||||
align-items: center;
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
.podcast-table th {
|
||||||
|
text-align: left;
|
||||||
|
padding: 0.75em;
|
||||||
|
border-bottom: 2px solid rgba(255,255,255,0.2);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.podcast-table td {
|
||||||
padding: 0.75em;
|
padding: 0.75em;
|
||||||
border-bottom: 1px solid rgba(255,255,255,0.1);
|
border-bottom: 1px solid rgba(255,255,255,0.1);
|
||||||
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
.podcast-item:last-child {
|
.podcast-table tr:last-child td {
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
.podcast-image {
|
.podcast-table tr:hover {
|
||||||
width: 50px;
|
background: rgba(255,255,255,0.05);
|
||||||
height: 50px;
|
|
||||||
max-width: 50px;
|
|
||||||
max-height: 50px;
|
|
||||||
min-width: 50px;
|
|
||||||
min-height: 50px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-right: 1em;
|
|
||||||
background: #333;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
.podcast-info {
|
.podcast-image {
|
||||||
flex: 1;
|
width: 50px !important;
|
||||||
|
height: 50px !important;
|
||||||
|
max-width: 50px !important;
|
||||||
|
max-height: 50px !important;
|
||||||
|
min-width: 50px !important;
|
||||||
|
min-height: 50px !important;
|
||||||
|
object-fit: cover !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
background: #333 !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
.col-image img {
|
||||||
|
width: 50px !important;
|
||||||
|
height: 50px !important;
|
||||||
|
max-width: 50px !important;
|
||||||
|
max-height: 50px !important;
|
||||||
}
|
}
|
||||||
.podcast-title {
|
.podcast-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin-bottom: 0.25em;
|
|
||||||
}
|
}
|
||||||
.podcast-meta {
|
.podcast-meta {
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
margin-top: 0.25em;
|
||||||
}
|
}
|
||||||
.podcast-actions {
|
.podcast-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5em;
|
gap: 0.5em;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.col-image {
|
||||||
|
width: 50px;
|
||||||
|
}
|
||||||
|
.col-actions {
|
||||||
|
width: 100px;
|
||||||
|
text-align: right;
|
||||||
}
|
}
|
||||||
.add-podcast-form {
|
.add-podcast-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -224,7 +246,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = podcasts.map(function(podcast) {
|
var rows = podcasts.map(function(podcast) {
|
||||||
// Handle both PascalCase (C#) and camelCase (JSON) property names
|
// Handle both PascalCase (C#) and camelCase (JSON) property names
|
||||||
var episodeCount = (podcast.Episodes || podcast.episodes || []).length;
|
var episodeCount = (podcast.Episodes || podcast.episodes || []).length;
|
||||||
var lastUpdated = podcast.LastUpdated || podcast.lastUpdated;
|
var lastUpdated = podcast.LastUpdated || podcast.lastUpdated;
|
||||||
@@ -235,25 +257,36 @@
|
|||||||
|
|
||||||
console.log('Jellypod: Rendering podcast:', podcastTitle, 'ID:', podcastId);
|
console.log('Jellypod: Rendering podcast:', podcastTitle, 'ID:', podcastId);
|
||||||
|
|
||||||
return '<div class="podcast-item" data-id="' + podcastId + '">' +
|
return '<tr data-id="' + podcastId + '">' +
|
||||||
'<img class="podcast-image" src="' + podcastImage + '" alt="" onerror="this.style.display=\'none\'">' +
|
'<td class="col-image">' +
|
||||||
'<div class="podcast-info">' +
|
'<img class="podcast-image" src="' + podcastImage + '" alt="" style="width:50px;height:50px;max-width:50px;max-height:50px;object-fit:cover;" onerror="this.style.display=\'none\'">' +
|
||||||
|
'</td>' +
|
||||||
|
'<td>' +
|
||||||
'<div class="podcast-title">' + escapeHtml(podcastTitle) + '</div>' +
|
'<div class="podcast-title">' + escapeHtml(podcastTitle) + '</div>' +
|
||||||
'<div class="podcast-meta">' +
|
'<div class="podcast-meta">' + episodeCount + ' episodes | Updated: ' + lastUpdatedStr + '</div>' +
|
||||||
episodeCount + ' episodes | Updated: ' + lastUpdatedStr +
|
'</td>' +
|
||||||
|
'<td class="col-actions">' +
|
||||||
|
'<div class="podcast-actions">' +
|
||||||
|
'<button is="emby-button" type="button" class="emby-button" onclick="refreshPodcast(\'' + podcastId + '\')" title="Refresh Feed">' +
|
||||||
|
'<span class="material-icons">refresh</span>' +
|
||||||
|
'</button>' +
|
||||||
|
'<button is="emby-button" type="button" class="emby-button" onclick="deletePodcast(\'' + podcastId + '\')" title="Unsubscribe">' +
|
||||||
|
'<span class="material-icons">delete</span>' +
|
||||||
|
'</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</td>' +
|
||||||
'<div class="podcast-actions">' +
|
'</tr>';
|
||||||
'<button is="emby-button" type="button" class="emby-button" onclick="refreshPodcast(\'' + podcastId + '\')" title="Refresh Feed">' +
|
|
||||||
'<span class="material-icons">refresh</span>' +
|
|
||||||
'</button>' +
|
|
||||||
'<button is="emby-button" type="button" class="emby-button" onclick="deletePodcast(\'' + podcastId + '\')" title="Unsubscribe">' +
|
|
||||||
'<span class="material-icons">delete</span>' +
|
|
||||||
'</button>' +
|
|
||||||
'</div>' +
|
|
||||||
'</div>';
|
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
var html = '<table class="podcast-table">' +
|
||||||
|
'<thead><tr>' +
|
||||||
|
'<th class="col-image"></th>' +
|
||||||
|
'<th>Podcast</th>' +
|
||||||
|
'<th class="col-actions">Actions</th>' +
|
||||||
|
'</tr></thead>' +
|
||||||
|
'<tbody>' + rows + '</tbody>' +
|
||||||
|
'</table>';
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
console.log('Jellypod: Rendered HTML length:', html.length);
|
console.log('Jellypod: Rendered HTML length:', html.length);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -8,6 +8,14 @@ A Jellyfin plugin that adds podcast support to your media server.
|
|||||||
- Automatic episode downloads
|
- Automatic episode downloads
|
||||||
- Integration with Jellyfin's library system
|
- Integration with Jellyfin's library system
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Podcast Library
|
||||||
|

|
||||||
|
|
||||||
|
### Plugin Settings
|
||||||
|

|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### Manual Installation
|
### Manual Installation
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
Reference in New Issue
Block a user