First POC with podcasts library
🏗️ Build Plugin / call (push) Failing after 0s
📝 Create/Update Release Draft & Release Bump PR / call (push) Failing after 0s
🔬 Run CodeQL / call (push) Failing after 0s
🧪 Test Plugin / call (push) Failing after 0s

This commit is contained in:
2025-12-13 23:57:58 +01:00
parent 7a9dbdafcc
commit 4679b77d1a
31 changed files with 2998 additions and 152 deletions
@@ -0,0 +1,365 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Mime;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Api.Models;
using Jellyfin.Plugin.Jellypod.Models;
using Jellyfin.Plugin.Jellypod.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Api;
/// <summary>
/// Controller for Jellypod podcast management.
/// </summary>
[ApiController]
[Authorize]
[Route("Jellypod")]
[Produces(MediaTypeNames.Application.Json)]
public class JellypodController : ControllerBase
{
private readonly ILogger<JellypodController> _logger;
private readonly IRssFeedService _rssFeedService;
private readonly IPodcastStorageService _storageService;
private readonly IPodcastDownloadService _downloadService;
/// <summary>
/// Initializes a new instance of the <see cref="JellypodController"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="rssFeedService">RSS feed service.</param>
/// <param name="storageService">Storage service.</param>
/// <param name="downloadService">Download service.</param>
public JellypodController(
ILogger<JellypodController> logger,
IRssFeedService rssFeedService,
IPodcastStorageService storageService,
IPodcastDownloadService downloadService)
{
_logger = logger;
_rssFeedService = rssFeedService;
_storageService = storageService;
_downloadService = downloadService;
}
/// <summary>
/// Gets all subscribed podcasts.
/// </summary>
/// <returns>List of podcasts.</returns>
[HttpGet("podcasts")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<IEnumerable<Podcast>>> GetPodcasts()
{
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
return Ok(podcasts);
}
/// <summary>
/// Gets a specific podcast by ID.
/// </summary>
/// <param name="id">Podcast ID.</param>
/// <returns>The podcast.</returns>
[HttpGet("podcasts/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Podcast>> GetPodcast([FromRoute] Guid id)
{
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
return podcast != null ? Ok(podcast) : NotFound();
}
/// <summary>
/// Adds a new podcast subscription.
/// </summary>
/// <param name="request">Add podcast request.</param>
/// <returns>The created podcast.</returns>
[HttpPost("podcasts")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<Podcast>> AddPodcast([FromBody] AddPodcastRequest request)
{
if (string.IsNullOrWhiteSpace(request.FeedUrl))
{
return BadRequest("Feed URL is required");
}
// Check if already subscribed
var existingPodcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
var existing = existingPodcasts.FirstOrDefault(p =>
string.Equals(p.FeedUrl, request.FeedUrl, StringComparison.OrdinalIgnoreCase));
if (existing != null)
{
return Conflict($"Already subscribed to this podcast: {existing.Title}");
}
// Fetch and validate the feed
var podcast = await _rssFeedService.FetchPodcastAsync(request.FeedUrl).ConfigureAwait(false);
if (podcast == null)
{
return BadRequest("Invalid or inaccessible RSS feed");
}
podcast.AutoDownloadEnabled = request.AutoDownload ?? true;
await _storageService.AddPodcastAsync(podcast).ConfigureAwait(false);
// Download podcast artwork
await _downloadService.DownloadPodcastArtworkAsync(podcast).ConfigureAwait(false);
_logger.LogInformation("Added podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl);
return CreatedAtAction(nameof(GetPodcast), new { id = podcast.Id }, podcast);
}
/// <summary>
/// Deletes a podcast subscription.
/// </summary>
/// <param name="id">Podcast ID.</param>
/// <param name="deleteFiles">Whether to delete downloaded files.</param>
/// <returns>No content.</returns>
[HttpDelete("podcasts/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeletePodcast([FromRoute] Guid id, [FromQuery] bool deleteFiles = false)
{
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
if (podcast == null)
{
return NotFound();
}
if (deleteFiles)
{
foreach (var episode in podcast.Episodes.Where(e => e.Status == EpisodeStatus.Downloaded))
{
await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false);
}
}
await _storageService.DeletePodcastAsync(id).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Updates a podcast's settings.
/// </summary>
/// <param name="id">Podcast ID.</param>
/// <param name="request">Update request.</param>
/// <returns>The updated podcast.</returns>
[HttpPut("podcasts/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Podcast>> UpdatePodcast([FromRoute] Guid id, [FromBody] UpdatePodcastRequest request)
{
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
if (podcast == null)
{
return NotFound();
}
if (request.AutoDownload.HasValue)
{
podcast.AutoDownloadEnabled = request.AutoDownload.Value;
}
if (request.MaxEpisodesToKeep.HasValue)
{
podcast.MaxEpisodesToKeep = request.MaxEpisodesToKeep.Value;
}
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
return Ok(podcast);
}
/// <summary>
/// Refreshes a podcast feed.
/// </summary>
/// <param name="id">Podcast ID.</param>
/// <returns>The updated podcast.</returns>
[HttpPost("podcasts/{id}/refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<Podcast>> RefreshPodcast([FromRoute] Guid id)
{
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
if (podcast == null)
{
return NotFound();
}
var updatedPodcast = await _rssFeedService.FetchPodcastAsync(podcast.FeedUrl).ConfigureAwait(false);
if (updatedPodcast == null)
{
return BadRequest("Failed to fetch podcast feed");
}
// Find new episodes
var existingGuids = podcast.Episodes
.Select(e => e.EpisodeGuid)
.Where(g => !string.IsNullOrEmpty(g))
.ToHashSet(StringComparer.Ordinal);
var newEpisodes = updatedPodcast.Episodes
.Where(e => !string.IsNullOrEmpty(e.EpisodeGuid) && !existingGuids.Contains(e.EpisodeGuid))
.ToList();
foreach (var episode in newEpisodes)
{
episode.PodcastId = podcast.Id;
podcast.Episodes.Insert(0, episode);
}
podcast.LastUpdated = DateTime.UtcNow;
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
return Ok(podcast);
}
/// <summary>
/// Downloads multiple episodes from a podcast.
/// </summary>
/// <param name="id">Podcast ID.</param>
/// <param name="request">Download request with count.</param>
/// <returns>Number of episodes queued.</returns>
[HttpPost("podcasts/{id}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult<DownloadResult>> DownloadEpisodes([FromRoute] Guid id, [FromBody] DownloadEpisodesRequest request)
{
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
if (podcast == null)
{
return NotFound();
}
var count = request.Count > 0 ? request.Count : 5;
var episodesToDownload = podcast.Episodes
.Where(e => e.Status == EpisodeStatus.Available)
.OrderByDescending(e => e.PublishedDate)
.Take(count)
.ToList();
foreach (var episode in episodesToDownload)
{
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
}
_logger.LogInformation("Queued {Count} episodes for download from {Podcast}", episodesToDownload.Count, podcast.Title);
return Ok(new DownloadResult { QueuedCount = episodesToDownload.Count });
}
/// <summary>
/// Downloads a specific episode.
/// </summary>
/// <param name="podcastId">Podcast ID.</param>
/// <param name="episodeId">Episode ID.</param>
/// <returns>Accepted status.</returns>
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/download")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DownloadEpisode([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();
}
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
return Accepted();
}
/// <summary>
/// Deletes an episode.
/// </summary>
/// <param name="podcastId">Podcast ID.</param>
/// <param name="episodeId">Episode ID.</param>
/// <param name="deleteFile">Whether to delete the downloaded file.</param>
/// <returns>No content.</returns>
[HttpDelete("podcasts/{podcastId}/episodes/{episodeId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task<ActionResult> DeleteEpisode(
[FromRoute] Guid podcastId,
[FromRoute] Guid episodeId,
[FromQuery] bool deleteFile = 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();
}
if (deleteFile && episode.Status == EpisodeStatus.Downloaded)
{
await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false);
}
podcast.Episodes.Remove(episode);
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
return NoContent();
}
/// <summary>
/// Previews a podcast feed without subscribing.
/// </summary>
/// <param name="request">Preview request.</param>
/// <returns>The podcast info.</returns>
[HttpPost("preview")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<Podcast>> PreviewFeed([FromBody] PreviewFeedRequest request)
{
if (string.IsNullOrWhiteSpace(request.FeedUrl))
{
return BadRequest("Feed URL is required");
}
var podcast = await _rssFeedService.FetchPodcastAsync(request.FeedUrl).ConfigureAwait(false);
return podcast != null ? Ok(podcast) : BadRequest("Invalid or inaccessible RSS feed");
}
/// <summary>
/// Removes duplicate podcast subscriptions, keeping the first one.
/// </summary>
/// <returns>Number of duplicates removed.</returns>
[HttpPost("cleanup")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<object>> CleanupDuplicates()
{
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
var seenUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var duplicatesToRemove = new List<Guid>();
foreach (var podcast in podcasts)
{
if (seenUrls.Contains(podcast.FeedUrl))
{
duplicatesToRemove.Add(podcast.Id);
}
else
{
seenUrls.Add(podcast.FeedUrl);
}
}
foreach (var id in duplicatesToRemove)
{
await _storageService.DeletePodcastAsync(id).ConfigureAwait(false);
}
_logger.LogInformation("Removed {Count} duplicate podcast subscriptions", duplicatesToRemove.Count);
return Ok(new { RemovedCount = duplicatesToRemove.Count });
}
}
@@ -0,0 +1,20 @@
using System.ComponentModel.DataAnnotations;
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Request to add a new podcast.
/// </summary>
public class AddPodcastRequest
{
/// <summary>
/// Gets or sets the RSS feed URL.
/// </summary>
[Required]
public string FeedUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets whether to enable auto-download.
/// </summary>
public bool? AutoDownload { get; set; }
}
@@ -0,0 +1,12 @@
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Request to download episodes from a podcast.
/// </summary>
public class DownloadEpisodesRequest
{
/// <summary>
/// Gets or sets the number of episodes to download.
/// </summary>
public int Count { get; set; } = 5;
}
@@ -0,0 +1,12 @@
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Result of a download request.
/// </summary>
public class DownloadResult
{
/// <summary>
/// Gets or sets the number of episodes queued for download.
/// </summary>
public int QueuedCount { get; set; }
}
@@ -0,0 +1,15 @@
using System.ComponentModel.DataAnnotations;
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Request to preview a feed.
/// </summary>
public class PreviewFeedRequest
{
/// <summary>
/// Gets or sets the RSS feed URL.
/// </summary>
[Required]
public string FeedUrl { get; set; } = string.Empty;
}
@@ -0,0 +1,17 @@
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Request to update a podcast.
/// </summary>
public class UpdatePodcastRequest
{
/// <summary>
/// Gets or sets whether to enable auto-download.
/// </summary>
public bool? AutoDownload { get; set; }
/// <summary>
/// Gets or sets the maximum episodes to keep.
/// </summary>
public int? MaxEpisodesToKeep { get; set; }
}
@@ -0,0 +1,85 @@
using System;
using System.Globalization;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Api;
/// <summary>
/// Controller for proxying podcast audio streams.
/// </summary>
[ApiController]
[Route("Jellypod/Stream")]
public class StreamProxyController : ControllerBase
{
private readonly ILogger<StreamProxyController> _logger;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="StreamProxyController"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
public StreamProxyController(
ILogger<StreamProxyController> logger,
IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
/// <summary>
/// Proxies an audio stream from a remote URL.
/// </summary>
/// <param name="url">Base64-encoded URL to stream.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The audio stream.</returns>
[HttpGet("{url}")]
[AllowAnonymous]
public async Task<IActionResult> GetStream([FromRoute] string url, CancellationToken cancellationToken)
{
try
{
// Decode the URL from base64
var decodedUrl = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(url));
_logger.LogDebug("Proxying audio stream from: {Url}", decodedUrl);
var client = _httpClientFactory.CreateClient("Jellypod");
// Make a streaming request
var request = new HttpRequestMessage(HttpMethod.Get, decodedUrl);
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch audio stream: {StatusCode}", response.StatusCode);
return StatusCode((int)response.StatusCode);
}
// Get content type
var contentType = response.Content.Headers.ContentType?.MediaType ?? "audio/mpeg";
var contentLength = response.Content.Headers.ContentLength;
// Stream the response
var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
if (contentLength.HasValue)
{
Response.Headers["Content-Length"] = contentLength.Value.ToString(CultureInfo.InvariantCulture);
}
Response.Headers["Accept-Ranges"] = "bytes";
return File(stream, contentType, enableRangeProcessing: true);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error proxying audio stream");
return StatusCode(500, "Failed to proxy audio stream");
}
}
}
@@ -0,0 +1,472 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
using Jellyfin.Plugin.Jellypod.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Channels;
/// <summary>
/// Jellypod channel for browsing and playing podcast episodes.
/// </summary>
public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallback
{
private readonly ILogger<JellypodChannel> _logger;
private readonly IPodcastStorageService _storageService;
private readonly IRssFeedService _rssFeedService;
private readonly IPodcastDownloadService _downloadService;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="JellypodChannel"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
/// <param name="storageService">The podcast storage service.</param>
/// <param name="rssFeedService">The RSS feed service.</param>
/// <param name="downloadService">The download service.</param>
/// <param name="appHost">The server application host.</param>
public JellypodChannel(
ILoggerFactory loggerFactory,
IPodcastStorageService storageService,
IRssFeedService rssFeedService,
IPodcastDownloadService downloadService,
IServerApplicationHost appHost)
{
_logger = loggerFactory.CreateLogger<JellypodChannel>();
_storageService = storageService;
_rssFeedService = rssFeedService;
_downloadService = downloadService;
_appHost = appHost;
_logger.LogDebug("JellypodChannel initialized");
}
/// <inheritdoc />
public string Name => "Podcasts";
/// <inheritdoc />
public string Description => "Browse and listen to your podcast subscriptions";
/// <inheritdoc />
public string DataVersion => "1.0";
/// <inheritdoc />
public string HomePageUrl => string.Empty;
/// <inheritdoc />
public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience;
/// <inheritdoc />
public InternalChannelFeatures GetChannelFeatures()
{
return new InternalChannelFeatures
{
ContentTypes = new List<ChannelMediaContentType>
{
ChannelMediaContentType.Podcast
},
MediaTypes = new List<ChannelMediaType>
{
ChannelMediaType.Audio
},
SupportsSortOrderToggle = true,
DefaultSortFields = new List<ChannelItemSortField>
{
ChannelItemSortField.PremiereDate,
ChannelItemSortField.DateCreated,
ChannelItemSortField.Name
},
AutoRefreshLevels = 1,
MaxPageSize = 100
};
}
/// <inheritdoc />
public Task<DynamicImageResponse> GetChannelImage(ImageType type, CancellationToken cancellationToken)
{
_logger.LogInformation("GetChannelImage called with type: {Type}", type);
var assembly = GetType().Assembly;
var resourceName = "Jellyfin.Plugin.Jellypod.Images.channel-icon.jpg";
// Log available resources for debugging
var resourceNames = assembly.GetManifestResourceNames();
_logger.LogInformation("Available embedded resources: {Resources}", string.Join(", ", resourceNames));
var stream = assembly.GetManifestResourceStream(resourceName);
if (stream != null)
{
_logger.LogInformation("Found channel icon, stream length: {Length}", stream.Length);
return Task.FromResult(new DynamicImageResponse
{
HasImage = true,
Stream = stream,
Format = MediaBrowser.Model.Drawing.ImageFormat.Jpg
});
}
_logger.LogWarning("Channel icon resource not found: {ResourceName}", resourceName);
return Task.FromResult(new DynamicImageResponse
{
HasImage = false
});
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedChannelImages()
{
return new List<ImageType>
{
ImageType.Primary,
ImageType.Thumb
};
}
/// <inheritdoc />
public async Task<ChannelItemResult> GetChannelItems(InternalChannelItemQuery query, CancellationToken cancellationToken)
{
_logger.LogDebug("GetChannelItems called for folder {FolderId}", query.FolderId);
try
{
var items = await GetFolderItemsAsync(query.FolderId, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Returning {Count} channel items for folder {FolderId}", items.Count, query.FolderId);
return new ChannelItemResult
{
Items = items,
TotalRecordCount = items.Count
};
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting channel items for folder {FolderId}", query.FolderId);
return new ChannelItemResult { Items = new List<ChannelItemInfo>(), TotalRecordCount = 0 };
}
}
private async Task<List<ChannelItemInfo>> GetFolderItemsAsync(string? folderId, CancellationToken cancellationToken)
{
// Root level - show all subscribed podcasts as folders
if (string.IsNullOrEmpty(folderId))
{
return await GetPodcastFoldersAsync(cancellationToken).ConfigureAwait(false);
}
// Podcast folder - show episodes
if (Guid.TryParse(folderId, out var podcastId))
{
return await GetPodcastEpisodesAsync(podcastId, cancellationToken).ConfigureAwait(false);
}
return new List<ChannelItemInfo>();
}
private async Task<List<ChannelItemInfo>> GetPodcastFoldersAsync(CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
foreach (var podcast in podcasts)
{
var episodeCount = podcast.Episodes.Count;
var downloadedCount = podcast.Episodes.Count(e => e.Status == EpisodeStatus.Downloaded);
items.Add(new ChannelItemInfo
{
Id = podcast.Id.ToString("N"),
Name = podcast.Title,
Overview = podcast.Description,
ImageUrl = podcast.ImageUrl,
Type = ChannelItemType.Folder,
FolderType = ChannelFolderType.Container,
DateCreated = podcast.DateAdded,
DateModified = podcast.LastUpdated
});
_logger.LogDebug("Added podcast folder: {Title} ({EpisodeCount} episodes, {DownloadedCount} downloaded)", podcast.Title, episodeCount, downloadedCount);
}
_logger.LogInformation("Returning {Count} podcast folders", items.Count);
return items;
}
private async Task<List<ChannelItemInfo>> GetPodcastEpisodesAsync(Guid podcastId, CancellationToken cancellationToken)
{
var items = new List<ChannelItemInfo>();
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
if (podcast == null)
{
_logger.LogWarning("Podcast {PodcastId} not found", podcastId);
return items;
}
// Sort episodes by published date, newest first
var episodes = podcast.Episodes
.OrderByDescending(e => e.PublishedDate)
.ToList();
foreach (var episode in episodes)
{
// 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,
ImageUrl = episode.ImageUrl ?? podcast.ImageUrl,
Type = ChannelItemType.Media,
ContentType = ChannelMediaContentType.Podcast,
MediaType = ChannelMediaType.Audio,
DateCreated = episode.PublishedDate,
PremiereDate = episode.PublishedDate,
RunTimeTicks = episode.Duration?.Ticks,
SeriesName = podcast.Title
// MediaSources intentionally omitted - see GetChannelItemMediaInfo
});
}
_logger.LogInformation("Returning {Count} episodes for podcast {PodcastTitle}", items.Count, podcast.Title);
return items;
}
private MediaSourceInfo CreateMediaSource(Podcast podcast, Episode episode)
{
// If episode is downloaded, use local file; otherwise proxy through our endpoint
var isLocal = episode.Status == EpisodeStatus.Downloaded && !string.IsNullOrEmpty(episode.LocalFilePath);
string path;
MediaProtocol protocol;
if (isLocal)
{
path = episode.LocalFilePath!;
protocol = MediaProtocol.File;
}
else
{
// Use proxy URL to avoid ffprobe issues with remote URLs
var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(episode.AudioUrl));
var serverUrl = _appHost.GetSmartApiUrl(string.Empty);
path = $"{serverUrl}/Jellypod/Stream/{encodedUrl}";
protocol = MediaProtocol.Http;
}
var container = GetContainerFromUrl(episode.AudioUrl);
var codec = GetCodecFromContainer(container);
// Create audio stream info - required for Jellyfin to play without probing
var audioStream = new MediaStream
{
Type = MediaStreamType.Audio,
Index = 0,
Codec = codec,
Channels = 2,
SampleRate = 44100,
BitRate = 128000,
IsDefault = true,
Language = "und"
};
return new MediaSourceInfo
{
Id = episode.Id.ToString("N"),
Name = episode.Title,
Path = path,
Protocol = protocol,
Container = container,
Type = MediaSourceType.Default,
IsRemote = !isLocal,
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = false,
SupportsProbing = false,
RequiresOpening = false,
RequiresClosing = false,
RunTimeTicks = episode.Duration?.Ticks,
Size = episode.FileSizeBytes,
Bitrate = 128000,
MediaStreams = new List<MediaStream> { audioStream },
DefaultAudioStreamIndex = 0,
ReadAtNativeFramerate = false,
AnalyzeDurationMs = 0,
IsInfiniteStream = false
};
}
private static string GetCodecFromContainer(string container)
{
return container switch
{
"mp3" => "mp3",
"m4a" => "aac",
"ogg" => "vorbis",
"opus" => "opus",
_ => "mp3"
};
}
private static string GetContainerFromUrl(string url)
{
try
{
var uri = new Uri(url);
var path = uri.AbsolutePath.ToLowerInvariant();
if (path.EndsWith(".mp3", StringComparison.Ordinal))
{
return "mp3";
}
if (path.EndsWith(".m4a", StringComparison.Ordinal))
{
return "m4a";
}
if (path.EndsWith(".ogg", StringComparison.Ordinal))
{
return "ogg";
}
if (path.EndsWith(".opus", StringComparison.Ordinal))
{
return "opus";
}
}
catch
{
// Ignore URL parsing errors
}
return "mp3"; // Default to mp3
}
/// <inheritdoc />
public string? GetCacheKey(string? userId)
{
// Use 5-minute time buckets for cache key
var now = DateTime.Now;
var timeBucket = new DateTime(now.Year, now.Month, now.Day, now.Hour, (now.Minute / 5) * 5, 0);
return timeBucket.ToString("yyyy-MM-dd-HH-mm", CultureInfo.InvariantCulture);
}
/// <inheritdoc />
public bool IsEnabledFor(string userId)
{
return true;
}
/// <inheritdoc />
public async Task<IEnumerable<MediaSourceInfo>> GetChannelItemMediaInfo(string id, CancellationToken cancellationToken)
{
_logger.LogDebug("GetChannelItemMediaInfo called for id: {Id}", id);
// Parse the episode ID
if (!Guid.TryParse(id, out var episodeId))
{
_logger.LogWarning("Invalid episode ID format: {Id}", id);
return Enumerable.Empty<MediaSourceInfo>();
}
// Find the episode across all podcasts
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
foreach (var podcast in podcasts)
{
var episode = podcast.Episodes.FirstOrDefault(e => e.Id == episodeId);
if (episode != null)
{
var currentPodcast = podcast;
var currentEpisode = episode;
// Download episode if not already downloaded
if (currentEpisode.Status != EpisodeStatus.Downloaded || string.IsNullOrEmpty(currentEpisode.LocalFilePath))
{
_logger.LogInformation("Downloading episode on demand: {Title}", currentEpisode.Title);
try
{
await _downloadService.DownloadEpisodeAsync(currentPodcast, currentEpisode, null, cancellationToken).ConfigureAwait(false);
// Reload the episode to get updated status
var reloadedPodcast = await _storageService.GetPodcastAsync(currentPodcast.Id).ConfigureAwait(false);
var reloadedEpisode = reloadedPodcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
if (reloadedEpisode == null)
{
_logger.LogError("Episode disappeared after download: {Id}", id);
return Enumerable.Empty<MediaSourceInfo>();
}
currentEpisode = reloadedEpisode;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to download episode: {Title}", currentEpisode.Title);
return Enumerable.Empty<MediaSourceInfo>();
}
}
var mediaSource = CreateMediaSourceForLocalFile(currentEpisode);
_logger.LogDebug("Returning media source for episode: {Title}, Path: {Path}", currentEpisode.Title, mediaSource.Path);
return new[] { mediaSource };
}
}
_logger.LogWarning("Episode not found: {Id}", id);
return Enumerable.Empty<MediaSourceInfo>();
}
private MediaSourceInfo CreateMediaSourceForLocalFile(Episode episode)
{
// This is for downloaded local files
var container = GetContainerFromUrl(episode.AudioUrl);
var codec = GetCodecFromContainer(container);
// Jellyfin requires MediaStreams with audio info for StreamBuilder.GetOptimalAudioStream
var audioStream = new MediaStream
{
Type = MediaStreamType.Audio,
Index = 0,
Codec = codec,
Channels = 2,
SampleRate = 44100,
BitRate = 128000,
IsDefault = true,
Language = "und"
};
return new MediaSourceInfo
{
Id = episode.Id.ToString("N"),
Name = episode.Title,
Path = episode.LocalFilePath!,
Protocol = MediaProtocol.File,
Container = container,
Type = MediaSourceType.Default,
IsRemote = false,
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
SupportsProbing = false, // Don't probe - we provide stream info
RequiresOpening = false,
RequiresClosing = false,
RunTimeTicks = episode.Duration?.Ticks,
Size = episode.FileSizeBytes,
Bitrate = 128000,
MediaStreams = new List<MediaStream> { audioStream },
DefaultAudioStreamIndex = 0,
ReadAtNativeFramerate = false
};
}
}
@@ -0,0 +1,59 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Jellypod.Configuration;
/// <summary>
/// Plugin configuration for Jellypod.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
public PluginConfiguration()
{
PodcastStoragePath = string.Empty;
UpdateIntervalHours = 6;
GlobalAutoDownloadEnabled = true;
MaxConcurrentDownloads = 2;
MaxEpisodesPerPodcast = 50;
CreatePodcastFolders = true;
DownloadNewEpisodesOnly = true;
}
/// <summary>
/// Gets or sets the path where podcasts will be stored.
/// Leave empty to use the default Jellyfin data path.
/// </summary>
public string PodcastStoragePath { get; set; }
/// <summary>
/// Gets or sets the update interval in hours for checking new episodes.
/// </summary>
public int UpdateIntervalHours { get; set; }
/// <summary>
/// Gets or sets a value indicating whether auto-download is enabled globally.
/// </summary>
public bool GlobalAutoDownloadEnabled { get; set; }
/// <summary>
/// Gets or sets the maximum number of concurrent downloads.
/// </summary>
public int MaxConcurrentDownloads { get; set; }
/// <summary>
/// Gets or sets the maximum episodes to keep per podcast (0 = unlimited).
/// </summary>
public int MaxEpisodesPerPodcast { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to create subfolders for each podcast.
/// </summary>
public bool CreatePodcastFolders { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to only download new episodes after subscription.
/// </summary>
public bool DownloadNewEpisodesOnly { get; set; }
}
@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Jellypod</title>
<style>
.podcast-item {
display: flex;
align-items: center;
padding: 0.75em;
border-bottom: 1px solid rgba(255,255,255,0.1);
}
.podcast-item:last-child {
border-bottom: none;
}
.podcast-image {
width: 50px;
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 {
flex: 1;
}
.podcast-title {
font-weight: bold;
margin-bottom: 0.25em;
}
.podcast-meta {
font-size: 0.85em;
opacity: 0.7;
}
.podcast-actions {
display: flex;
gap: 0.5em;
}
.add-podcast-form {
display: flex;
gap: 0.5em;
align-items: flex-end;
margin-bottom: 1em;
}
.add-podcast-form .inputContainer {
flex: 1;
margin: 0;
}
.section-divider {
margin: 2em 0;
border-top: 1px solid rgba(255,255,255,0.2);
}
.empty-state {
text-align: center;
padding: 2em;
opacity: 0.7;
}
.info-box {
background: rgba(0,100,200,0.2);
border: 1px solid rgba(0,100,200,0.4);
border-radius: 4px;
padding: 1em;
margin-bottom: 1em;
}
</style>
</head>
<body>
<div id="JellypodConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<h2 class="sectionTitle">Jellypod Settings</h2>
<div class="info-box">
<strong>Browse Podcasts:</strong> Your subscribed podcasts appear in the <em>Channels</em> section of Jellyfin's main menu.
Use this settings page to add/remove podcast subscriptions and configure download options.
</div>
<form id="JellypodConfigForm">
<!-- Storage Settings -->
<div class="verticalSection">
<h3 class="sectionTitle">Storage</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="PodcastStoragePath">
Download Storage Path
</label>
<input id="PodcastStoragePath" name="PodcastStoragePath" type="text" is="emby-input" />
<div class="fieldDescription">
Path where downloaded episodes are stored. Leave empty for default location.
</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="CreatePodcastFolders" name="CreatePodcastFolders" type="checkbox" is="emby-checkbox" />
<span>Create subfolders for each podcast</span>
</label>
</div>
</div>
<!-- Update Settings -->
<div class="verticalSection">
<h3 class="sectionTitle">Feed Updates</h3>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="UpdateIntervalHours">
Update Interval (hours)
</label>
<input id="UpdateIntervalHours" name="UpdateIntervalHours" type="number" is="emby-input" min="1" max="168" />
<div class="fieldDescription">
How often to check for new episodes (default: 6 hours)
</div>
</div>
</div>
<!-- Download Settings -->
<div class="verticalSection">
<h3 class="sectionTitle">Downloads</h3>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input id="GlobalAutoDownloadEnabled" name="GlobalAutoDownloadEnabled" type="checkbox" is="emby-checkbox" />
<span>Automatically download new episodes</span>
</label>
<div class="fieldDescription">
Episodes can always be streamed directly. Enable this to also download them locally.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MaxConcurrentDownloads">
Max Concurrent Downloads
</label>
<input id="MaxConcurrentDownloads" name="MaxConcurrentDownloads" type="number" is="emby-input" min="1" max="5" />
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="MaxEpisodesPerPodcast">
Max Episodes Per Podcast
</label>
<input id="MaxEpisodesPerPodcast" name="MaxEpisodesPerPodcast" type="number" is="emby-input" min="0" />
<div class="fieldDescription">
Maximum episodes to keep downloaded per podcast (0 = unlimited)
</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save Settings</span>
</button>
</div>
</form>
<div class="section-divider"></div>
<!-- Podcast Subscription Management -->
<div class="verticalSection">
<h2 class="sectionTitle">Podcast Subscriptions</h2>
<!-- Add Podcast Form -->
<div class="add-podcast-form">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="NewFeedUrl">
RSS Feed URL
</label>
<input id="NewFeedUrl" type="url" is="emby-input" placeholder="https://example.com/feed.xml" />
</div>
<button is="emby-button" type="button" id="btnAddPodcast" class="raised emby-button">
<span>Subscribe</span>
</button>
</div>
<!-- Podcast List -->
<div id="podcastList">
<div class="empty-state" id="emptyState">
No podcast subscriptions yet. Add one above, then browse in Channels.
</div>
</div>
</div>
</div>
</div>
<script type="text/javascript">
var JellypodConfig = {
pluginUniqueId: 'c713faf4-4e50-4e87-941a-1200178ed605'
};
function loadConfig() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JellypodConfig.pluginUniqueId).then(function (config) {
document.querySelector('#PodcastStoragePath').value = config.PodcastStoragePath || '';
document.querySelector('#UpdateIntervalHours').value = config.UpdateIntervalHours;
document.querySelector('#GlobalAutoDownloadEnabled').checked = config.GlobalAutoDownloadEnabled;
document.querySelector('#MaxConcurrentDownloads').value = config.MaxConcurrentDownloads;
document.querySelector('#MaxEpisodesPerPodcast').value = config.MaxEpisodesPerPodcast;
document.querySelector('#CreatePodcastFolders').checked = config.CreatePodcastFolders;
Dashboard.hideLoadingMsg();
});
}
function loadPodcasts() {
console.log('Jellypod: Loading podcasts...');
ApiClient.fetch({
url: ApiClient.getUrl('Jellypod/podcasts'),
type: 'GET'
}).then(function(response) {
// ApiClient.fetch returns a Response object, need to parse JSON
return response.json();
}).then(function(podcasts) {
console.log('Jellypod: Received podcasts:', podcasts);
renderPodcasts(podcasts);
}).catch(function(err) {
console.error('Jellypod: Failed to load podcasts:', err);
renderPodcasts([]);
});
}
function renderPodcasts(podcasts) {
var container = document.querySelector('#podcastList');
console.log('Jellypod: Rendering podcasts, count:', podcasts ? podcasts.length : 0);
if (!podcasts || podcasts.length === 0) {
container.innerHTML = '<div class="empty-state">No podcast subscriptions yet. Add one above, then browse in Channels.</div>';
return;
}
var html = podcasts.map(function(podcast) {
// Handle both PascalCase (C#) and camelCase (JSON) property names
var episodeCount = (podcast.Episodes || podcast.episodes || []).length;
var lastUpdated = podcast.LastUpdated || podcast.lastUpdated;
var lastUpdatedStr = lastUpdated ? new Date(lastUpdated).toLocaleString() : 'Never';
var podcastId = podcast.Id || podcast.id;
var podcastTitle = podcast.Title || podcast.title || 'Unknown';
var podcastImage = podcast.ImageUrl || podcast.imageUrl || '';
console.log('Jellypod: Rendering podcast:', podcastTitle, 'ID:', podcastId);
return '<div class="podcast-item" data-id="' + podcastId + '">' +
'<img class="podcast-image" src="' + podcastImage + '" alt="" onerror="this.style.display=\'none\'">' +
'<div class="podcast-info">' +
'<div class="podcast-title">' + escapeHtml(podcastTitle) + '</div>' +
'<div class="podcast-meta">' +
episodeCount + ' episodes | Updated: ' + lastUpdatedStr +
'</div>' +
'</div>' +
'<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>';
}).join('');
container.innerHTML = html;
console.log('Jellypod: Rendered HTML length:', html.length);
}
function escapeHtml(text) {
var div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function addPodcast() {
var feedUrl = document.querySelector('#NewFeedUrl').value.trim();
if (!feedUrl) {
Dashboard.alert('Please enter a feed URL');
return;
}
Dashboard.showLoadingMsg();
ApiClient.fetch({
url: ApiClient.getUrl('Jellypod/podcasts'),
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ feedUrl: feedUrl })
}).then(function(podcast) {
document.querySelector('#NewFeedUrl').value = '';
Dashboard.hideLoadingMsg();
loadPodcasts();
Dashboard.alert('Subscribed to: ' + podcast.title + '\n\nBrowse episodes in Channels > Podcasts');
}).catch(function(err) {
Dashboard.hideLoadingMsg();
Dashboard.alert('Failed to subscribe. Please check the URL and try again.');
});
}
window.refreshPodcast = function(id) {
Dashboard.showLoadingMsg();
ApiClient.fetch({
url: ApiClient.getUrl('Jellypod/podcasts/' + id + '/refresh'),
type: 'POST'
}).then(function() {
Dashboard.hideLoadingMsg();
loadPodcasts();
Dashboard.alert('Feed refreshed');
}).catch(function(err) {
Dashboard.hideLoadingMsg();
Dashboard.alert('Failed to refresh feed');
});
};
window.deletePodcast = function(id) {
console.log('Jellypod: deletePodcast called with id:', id);
// Use simple confirm since require(['confirm']) may not work in newer Jellyfin
if (confirm('Are you sure you want to unsubscribe from this podcast?')) {
console.log('Jellypod: User confirmed deletion');
Dashboard.showLoadingMsg();
ApiClient.fetch({
url: ApiClient.getUrl('Jellypod/podcasts/' + id + '?deleteFiles=true'),
type: 'DELETE'
}).then(function() {
console.log('Jellypod: Delete successful');
Dashboard.hideLoadingMsg();
loadPodcasts();
}).catch(function(err) {
console.error('Jellypod: Delete failed:', err);
Dashboard.hideLoadingMsg();
Dashboard.alert('Failed to unsubscribe');
});
}
};
// Jellyfin uses 'viewshow' event for SPA navigation, not 'pageshow'
document.querySelector('#JellypodConfigPage').addEventListener('viewshow', function() {
console.log('Jellypod: viewshow event fired');
loadConfig();
loadPodcasts();
});
// Also handle pageshow as fallback
document.querySelector('#JellypodConfigPage').addEventListener('pageshow', function() {
console.log('Jellypod: pageshow event fired');
loadConfig();
loadPodcasts();
});
// Initialize immediately if the page is already visible (handles direct page load)
(function() {
console.log('Jellypod: Script loaded, checking if should initialize...');
// Small delay to ensure Jellyfin's framework is ready
setTimeout(function() {
var page = document.querySelector('#JellypodConfigPage');
if (page && page.offsetParent !== null) {
console.log('Jellypod: Page visible, initializing...');
loadConfig();
loadPodcasts();
}
}, 100);
})();
document.querySelector('#JellypodConfigForm').addEventListener('submit', function(e) {
e.preventDefault();
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JellypodConfig.pluginUniqueId).then(function (config) {
config.PodcastStoragePath = document.querySelector('#PodcastStoragePath').value;
config.UpdateIntervalHours = parseInt(document.querySelector('#UpdateIntervalHours').value, 10);
config.GlobalAutoDownloadEnabled = document.querySelector('#GlobalAutoDownloadEnabled').checked;
config.MaxConcurrentDownloads = parseInt(document.querySelector('#MaxConcurrentDownloads').value, 10);
config.MaxEpisodesPerPodcast = parseInt(document.querySelector('#MaxEpisodesPerPodcast').value, 10);
config.CreatePodcastFolders = document.querySelector('#CreatePodcastFolders').checked;
ApiClient.updatePluginConfiguration(JellypodConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
return false;
});
document.querySelector('#btnAddPodcast').addEventListener('click', addPodcast);
document.querySelector('#NewFeedUrl').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addPodcast();
}
});
</script>
</div>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Jellypod</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<CodeAnalysisRuleSet>../jellyfin.ruleset</CodeAnalysisRuleSet>
<CopyLocalLockFileAssemblies>true</CopyLocalLockFileAssemblies>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.9.11">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.9.11">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
<PackageReference Include="System.ServiceModel.Syndication" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="SerilogAnalyzer" Version="0.15.0" PrivateAssets="All" />
<PackageReference Include="StyleCop.Analyzers" Version="1.2.0-beta.556" PrivateAssets="All" />
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
<None Remove="Images\channel-icon.jpg" />
<EmbeddedResource Include="Images\channel-icon.jpg" />
</ItemGroup>
</Project>
@@ -0,0 +1,84 @@
using System;
namespace Jellyfin.Plugin.Jellypod.Models;
/// <summary>
/// Represents a podcast episode.
/// </summary>
public class Episode
{
/// <summary>
/// Gets or sets the unique identifier for this episode.
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Gets or sets the ID of the parent podcast.
/// </summary>
public Guid PodcastId { get; set; }
/// <summary>
/// Gets or sets the episode title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the episode description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the URL to the audio file.
/// </summary>
public string AudioUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the local file path where the episode is stored.
/// </summary>
public string? LocalFilePath { get; set; }
/// <summary>
/// Gets or sets the file size in bytes.
/// </summary>
public long? FileSizeBytes { get; set; }
/// <summary>
/// Gets or sets the episode duration.
/// </summary>
public TimeSpan? Duration { get; set; }
/// <summary>
/// Gets or sets the publish date.
/// </summary>
public DateTime PublishedDate { get; set; }
/// <summary>
/// Gets or sets the date the episode was downloaded.
/// </summary>
public DateTime? DownloadedDate { get; set; }
/// <summary>
/// Gets or sets the download status.
/// </summary>
public EpisodeStatus Status { get; set; } = EpisodeStatus.Available;
/// <summary>
/// Gets or sets the RSS GUID for deduplication.
/// </summary>
public string? EpisodeGuid { get; set; }
/// <summary>
/// Gets or sets the season number if applicable.
/// </summary>
public int? SeasonNumber { get; set; }
/// <summary>
/// Gets or sets the episode number if applicable.
/// </summary>
public int? EpisodeNumber { get; set; }
/// <summary>
/// Gets or sets the episode-specific image URL.
/// </summary>
public string? ImageUrl { get; set; }
}
@@ -0,0 +1,30 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.Jellypod.Models;
/// <summary>
/// Represents the download status of a podcast episode.
/// </summary>
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum EpisodeStatus
{
/// <summary>
/// Episode is known but not downloaded.
/// </summary>
Available,
/// <summary>
/// Episode is currently being downloaded.
/// </summary>
Downloading,
/// <summary>
/// Episode has been downloaded and is available locally.
/// </summary>
Downloaded,
/// <summary>
/// Download failed.
/// </summary>
Error
}
@@ -0,0 +1,77 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
namespace Jellyfin.Plugin.Jellypod.Models;
/// <summary>
/// Represents a podcast subscription.
/// </summary>
public class Podcast
{
/// <summary>
/// Gets or sets the unique identifier for this podcast.
/// </summary>
public Guid Id { get; set; } = Guid.NewGuid();
/// <summary>
/// Gets or sets the podcast title.
/// </summary>
public string Title { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the podcast description.
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the RSS feed URL.
/// </summary>
public string FeedUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the podcast artwork URL.
/// </summary>
public string? ImageUrl { get; set; }
/// <summary>
/// Gets or sets the podcast author/publisher.
/// </summary>
public string? Author { get; set; }
/// <summary>
/// Gets or sets the podcast language.
/// </summary>
public string? Language { get; set; }
/// <summary>
/// Gets or sets the podcast category.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Gets or sets the last time the feed was updated.
/// </summary>
public DateTime LastUpdated { get; set; }
/// <summary>
/// Gets or sets the date this podcast was added.
/// </summary>
public DateTime DateAdded { get; set; } = DateTime.UtcNow;
/// <summary>
/// Gets or sets a value indicating whether auto-download is enabled for this podcast.
/// </summary>
public bool AutoDownloadEnabled { get; set; } = true;
/// <summary>
/// Gets or sets the maximum episodes to keep (0 = unlimited).
/// </summary>
public int MaxEpisodesToKeep { get; set; }
/// <summary>
/// Gets or sets the list of episodes.
/// </summary>
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
public Collection<Episode> Episodes { get; set; } = new();
}
@@ -0,0 +1,22 @@
using System;
using System.Collections.ObjectModel;
using System.Diagnostics.CodeAnalysis;
namespace Jellyfin.Plugin.Jellypod.Models;
/// <summary>
/// Container for podcast data persistence.
/// </summary>
public class PodcastDatabase
{
/// <summary>
/// Gets or sets the list of subscribed podcasts.
/// </summary>
[SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")]
public Collection<Podcast> Podcasts { get; set; } = new();
/// <summary>
/// Gets or sets the last time the database was saved.
/// </summary>
public DateTime LastSaved { get; set; }
}
+51
View File
@@ -0,0 +1,51 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.Jellypod.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.Jellypod;
/// <summary>
/// The main plugin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
/// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param>
/// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "Jellypod";
/// <inheritdoc />
public override Guid Id => Guid.Parse("c713faf4-4e50-4e87-941a-1200178ed605");
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(CultureInfo.InvariantCulture, "{0}.Configuration.configPage.html", GetType().Namespace)
}
];
}
}
@@ -0,0 +1,33 @@
using Jellyfin.Plugin.Jellypod.Channels;
using Jellyfin.Plugin.Jellypod.Services;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Jellypod;
/// <summary>
/// Registers plugin services with the dependency injection container.
/// </summary>
public class PluginServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Register HTTP client
serviceCollection.AddHttpClient("Jellypod", client =>
{
client.DefaultRequestHeaders.Add("User-Agent", "Jellypod/1.0 (Jellyfin Podcast Plugin)");
client.Timeout = System.TimeSpan.FromMinutes(10);
});
// Register services
serviceCollection.AddSingleton<IRssFeedService, RssFeedService>();
serviceCollection.AddSingleton<IPodcastStorageService, PodcastStorageService>();
serviceCollection.AddSingleton<IPodcastDownloadService, PodcastDownloadService>();
// Register channel
serviceCollection.AddSingleton<IChannel, JellypodChannel>();
}
}
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Services;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.ScheduledTasks;
/// <summary>
/// Scheduled task that updates all podcast feeds and downloads new episodes.
/// </summary>
public class PodcastUpdateTask : IScheduledTask
{
private readonly ILogger<PodcastUpdateTask> _logger;
private readonly IRssFeedService _rssFeedService;
private readonly IPodcastStorageService _storageService;
private readonly IPodcastDownloadService _downloadService;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastUpdateTask"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="rssFeedService">RSS feed service.</param>
/// <param name="storageService">Storage service.</param>
/// <param name="downloadService">Download service.</param>
public PodcastUpdateTask(
ILogger<PodcastUpdateTask> logger,
IRssFeedService rssFeedService,
IPodcastStorageService storageService,
IPodcastDownloadService downloadService)
{
_logger = logger;
_rssFeedService = rssFeedService;
_storageService = storageService;
_downloadService = downloadService;
}
/// <inheritdoc />
public string Name => "Update Podcast Feeds";
/// <inheritdoc />
public string Key => "JellypodUpdateFeeds";
/// <inheritdoc />
public string Description => "Checks all subscribed podcasts for new episodes and downloads them.";
/// <inheritdoc />
public string Category => "Jellypod";
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_logger.LogInformation("Starting podcast feed update task");
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
var totalPodcasts = podcasts.Count;
var processedCount = 0;
foreach (var podcast in podcasts)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
_logger.LogDebug("Updating podcast: {Title}", podcast.Title);
var updatedPodcast = await _rssFeedService.FetchPodcastAsync(podcast.FeedUrl, cancellationToken).ConfigureAwait(false);
if (updatedPodcast != null)
{
// Find new episodes (by GUID)
var existingGuids = podcast.Episodes
.Select(e => e.EpisodeGuid)
.Where(g => !string.IsNullOrEmpty(g))
.ToHashSet(StringComparer.Ordinal);
var newEpisodes = updatedPodcast.Episodes
.Where(e => !string.IsNullOrEmpty(e.EpisodeGuid) && !existingGuids.Contains(e.EpisodeGuid))
.ToList();
if (newEpisodes.Count > 0)
{
_logger.LogInformation("Found {Count} new episodes for {Title}", newEpisodes.Count, podcast.Title);
// Add new episodes to podcast
foreach (var episode in newEpisodes)
{
episode.PodcastId = podcast.Id;
podcast.Episodes.Insert(0, episode);
}
podcast.LastUpdated = DateTime.UtcNow;
// Auto-download if enabled
var config = Plugin.Instance?.Configuration;
if (config?.GlobalAutoDownloadEnabled == true && podcast.AutoDownloadEnabled)
{
foreach (var episode in newEpisodes)
{
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
}
}
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
}
else
{
_logger.LogDebug("No new episodes for {Title}", podcast.Title);
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update podcast: {Title}", podcast.Title);
}
processedCount++;
progress.Report((double)processedCount / totalPodcasts * 100);
}
progress.Report(100);
_logger.LogInformation("Podcast feed update task completed");
}
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
var config = Plugin.Instance?.Configuration;
var intervalHours = config?.UpdateIntervalHours ?? 6;
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfo.TriggerInterval,
IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks
}
};
}
}
@@ -0,0 +1,49 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for downloading podcast episodes.
/// </summary>
public interface IPodcastDownloadService
{
/// <summary>
/// Queues an episode for download.
/// </summary>
/// <param name="podcast">The podcast.</param>
/// <param name="episode">The episode to download.</param>
/// <returns>Task representing the queue operation.</returns>
Task QueueDownloadAsync(Podcast podcast, Episode episode);
/// <summary>
/// Downloads an episode immediately.
/// </summary>
/// <param name="podcast">The podcast.</param>
/// <param name="episode">The episode to download.</param>
/// <param name="progress">Optional progress reporter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The local file path of the downloaded episode.</returns>
Task<string> DownloadEpisodeAsync(
Podcast podcast,
Episode episode,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a downloaded episode file.
/// </summary>
/// <param name="episode">The episode whose file to delete.</param>
/// <returns>Task representing the delete operation.</returns>
Task DeleteEpisodeFileAsync(Episode episode);
/// <summary>
/// Downloads podcast artwork.
/// </summary>
/// <param name="podcast">The podcast.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task representing the download operation.</returns>
Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for storing and retrieving podcast data.
/// </summary>
public interface IPodcastStorageService
{
/// <summary>
/// Gets all subscribed podcasts.
/// </summary>
/// <returns>List of all podcasts.</returns>
Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync();
/// <summary>
/// Gets a podcast by its ID.
/// </summary>
/// <param name="id">The podcast ID.</param>
/// <returns>The podcast, or null if not found.</returns>
Task<Podcast?> GetPodcastAsync(Guid id);
/// <summary>
/// Adds a new podcast subscription.
/// </summary>
/// <param name="podcast">The podcast to add.</param>
/// <returns>Task representing the operation.</returns>
Task AddPodcastAsync(Podcast podcast);
/// <summary>
/// Updates an existing podcast.
/// </summary>
/// <param name="podcast">The podcast to update.</param>
/// <returns>Task representing the operation.</returns>
Task UpdatePodcastAsync(Podcast podcast);
/// <summary>
/// Deletes a podcast subscription.
/// </summary>
/// <param name="id">The podcast ID to delete.</param>
/// <returns>Task representing the operation.</returns>
Task DeletePodcastAsync(Guid id);
/// <summary>
/// Gets the local file path for an episode.
/// </summary>
/// <param name="podcast">The parent podcast.</param>
/// <param name="episode">The episode.</param>
/// <returns>The file path where the episode should be stored.</returns>
string GetEpisodeFilePath(Podcast podcast, Episode episode);
/// <summary>
/// Gets the storage path for podcasts.
/// </summary>
/// <returns>The base path for podcast storage.</returns>
string GetStoragePath();
}
@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for fetching and parsing podcast RSS feeds.
/// </summary>
public interface IRssFeedService
{
/// <summary>
/// Fetches and parses a podcast from an RSS feed URL.
/// </summary>
/// <param name="feedUrl">The RSS feed URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed podcast with episodes, or null if parsing failed.</returns>
Task<Podcast?> FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,248 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for downloading podcast episodes.
/// </summary>
public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposable
{
private readonly ILogger<PodcastDownloadService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IPodcastStorageService _storageService;
private readonly ConcurrentQueue<(Podcast Podcast, Episode Episode)> _downloadQueue = new();
private readonly SemaphoreSlim _downloadSemaphore;
private int _isProcessing;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastDownloadService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
/// <param name="storageService">Storage service.</param>
public PodcastDownloadService(
ILogger<PodcastDownloadService> logger,
IHttpClientFactory httpClientFactory,
IPodcastStorageService storageService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_storageService = storageService;
var maxConcurrent = Plugin.Instance?.Configuration?.MaxConcurrentDownloads ?? 2;
_downloadSemaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
}
/// <inheritdoc />
public Task QueueDownloadAsync(Podcast podcast, Episode episode)
{
_downloadQueue.Enqueue((podcast, episode));
_logger.LogDebug("Queued download: {PodcastTitle} - {EpisodeTitle}", podcast.Title, episode.Title);
// Start processing if not already running
if (Interlocked.CompareExchange(ref _isProcessing, 1, 0) == 0)
{
_ = ProcessQueueAsync();
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<string> DownloadEpisodeAsync(
Podcast podcast,
Episode episode,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
var filePath = _storageService.GetEpisodeFilePath(podcast, episode);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
_logger.LogInformation("Downloading episode: {Title} to {Path}", episode.Title, filePath);
try
{
episode.Status = EpisodeStatus.Downloading;
var httpClient = _httpClientFactory.CreateClient("Jellypod");
using var response = await httpClient.GetAsync(
episode.AudioUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1;
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
long totalRead;
try
{
var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
try
{
var buffer = new byte[81920];
totalRead = 0L;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
totalRead += bytesRead;
if (totalBytes > 0)
{
progress?.Report((double)totalRead / totalBytes * 100);
}
}
}
finally
{
await fileStream.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
await contentStream.DisposeAsync().ConfigureAwait(false);
}
episode.LocalFilePath = filePath;
episode.Status = EpisodeStatus.Downloaded;
episode.DownloadedDate = DateTime.UtcNow;
episode.FileSizeBytes = totalRead;
_logger.LogInformation("Downloaded episode: {Title} ({Size} bytes)", episode.Title, totalRead);
// Update the podcast in storage
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
return filePath;
}
catch (Exception ex)
{
episode.Status = EpisodeStatus.Error;
_logger.LogError(ex, "Failed to download episode: {Title}", episode.Title);
throw;
}
}
/// <inheritdoc />
public Task DeleteEpisodeFileAsync(Episode episode)
{
if (string.IsNullOrEmpty(episode.LocalFilePath))
{
return Task.CompletedTask;
}
try
{
if (File.Exists(episode.LocalFilePath))
{
File.Delete(episode.LocalFilePath);
_logger.LogInformation("Deleted episode file: {Path}", episode.LocalFilePath);
}
episode.LocalFilePath = null;
episode.Status = EpisodeStatus.Available;
episode.DownloadedDate = null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete episode file: {Path}", episode.LocalFilePath);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(podcast.ImageUrl))
{
return;
}
var config = Plugin.Instance?.Configuration;
if (config?.CreatePodcastFolders != true)
{
return;
}
var basePath = _storageService.GetStoragePath();
var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title));
var artworkPath = Path.Combine(podcastFolder, "folder.jpg");
if (File.Exists(artworkPath))
{
return;
}
try
{
Directory.CreateDirectory(podcastFolder);
var httpClient = _httpClientFactory.CreateClient("Jellypod");
var imageBytes = await httpClient.GetByteArrayAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false);
await File.WriteAllBytesAsync(artworkPath, imageBytes, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Downloaded artwork for podcast: {Title}", podcast.Title);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download artwork for podcast: {Title}", podcast.Title);
}
}
private async Task ProcessQueueAsync()
{
try
{
while (_downloadQueue.TryDequeue(out var item))
{
await _downloadSemaphore.WaitAsync().ConfigureAwait(false);
try
{
await DownloadEpisodeAsync(item.Podcast, item.Episode).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process queued download: {Title}", item.Episode.Title);
}
finally
{
_downloadSemaphore.Release();
}
}
}
finally
{
Interlocked.Exchange(ref _isProcessing, 0);
}
}
private static string SanitizeFileName(string name)
{
var invalidChars = Path.GetInvalidFileNameChars();
var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray());
return result.Length > 100 ? result.Substring(0, 100).Trim() : result.Trim();
}
/// <inheritdoc />
public void Dispose()
{
_downloadSemaphore.Dispose();
}
}
@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for storing and retrieving podcast data.
/// </summary>
public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly ILogger<PodcastStorageService> _logger;
private readonly IApplicationPaths _applicationPaths;
private readonly SemaphoreSlim _dbLock = new(1, 1);
private PodcastDatabase? _cache;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastStorageService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="applicationPaths">Application paths.</param>
public PodcastStorageService(ILogger<PodcastStorageService> logger, IApplicationPaths applicationPaths)
{
_logger = logger;
_applicationPaths = applicationPaths;
_logger.LogInformation("Jellypod database path: {Path}", DatabasePath);
_logger.LogInformation("PluginConfigurationsPath: {Path}", applicationPaths.PluginConfigurationsPath);
}
private string DatabasePath => Path.Combine(
_applicationPaths.PluginConfigurationsPath,
"Jellypod",
"podcasts.json");
/// <inheritdoc />
public async Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync()
{
var db = await LoadDatabaseAsync().ConfigureAwait(false);
return db.Podcasts.ToList();
}
/// <inheritdoc />
public async Task<Podcast?> GetPodcastAsync(Guid id)
{
var db = await LoadDatabaseAsync().ConfigureAwait(false);
return db.Podcasts.FirstOrDefault(p => p.Id == id);
}
/// <inheritdoc />
public async Task AddPodcastAsync(Podcast podcast)
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
db.Podcasts.Add(podcast);
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
_logger.LogInformation("Added podcast: {Title}", podcast.Title);
}
finally
{
_dbLock.Release();
}
}
/// <inheritdoc />
public async Task UpdatePodcastAsync(Podcast podcast)
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
var existing = db.Podcasts.FirstOrDefault(p => p.Id == podcast.Id);
if (existing != null)
{
var index = db.Podcasts.IndexOf(existing);
db.Podcasts[index] = podcast;
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
_logger.LogDebug("Updated podcast: {Title}", podcast.Title);
}
}
finally
{
_dbLock.Release();
}
}
/// <inheritdoc />
public async Task DeletePodcastAsync(Guid id)
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
var podcast = db.Podcasts.FirstOrDefault(p => p.Id == id);
if (podcast != null)
{
db.Podcasts.Remove(podcast);
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
_logger.LogInformation("Deleted podcast: {Title}", podcast.Title);
}
}
finally
{
_dbLock.Release();
}
}
/// <inheritdoc />
public string GetEpisodeFilePath(Podcast podcast, Episode episode)
{
var basePath = GetStoragePath();
var config = Plugin.Instance?.Configuration;
var safePodcastTitle = SanitizeFileName(podcast.Title);
var safeEpisodeTitle = SanitizeFileName(episode.Title);
var extension = GetAudioExtension(episode.AudioUrl);
// Format: YYYY-MM-DD - Episode Title.mp3
var datePrefix = episode.PublishedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var fileName = $"{datePrefix} - {safeEpisodeTitle}{extension}";
if (config?.CreatePodcastFolders == true)
{
return Path.Combine(basePath, safePodcastTitle, fileName);
}
return Path.Combine(basePath, $"{safePodcastTitle} - {fileName}");
}
/// <inheritdoc />
public string GetStoragePath()
{
var config = Plugin.Instance?.Configuration;
if (!string.IsNullOrEmpty(config?.PodcastStoragePath))
{
return config.PodcastStoragePath;
}
return Path.Combine(_applicationPaths.DataPath, "Podcasts");
}
private async Task<PodcastDatabase> LoadDatabaseAsync()
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
return await LoadDatabaseInternalAsync().ConfigureAwait(false);
}
finally
{
_dbLock.Release();
}
}
private async Task<PodcastDatabase> LoadDatabaseInternalAsync()
{
if (_cache != null)
{
return _cache;
}
if (!File.Exists(DatabasePath))
{
_logger.LogWarning("Database file does not exist at {Path}", DatabasePath);
_cache = new PodcastDatabase();
return _cache;
}
try
{
_logger.LogInformation("Loading database from {Path}", DatabasePath);
var json = await File.ReadAllTextAsync(DatabasePath).ConfigureAwait(false);
_logger.LogInformation("Read {Length} characters from database file", json.Length);
_cache = JsonSerializer.Deserialize<PodcastDatabase>(json, JsonOptions) ?? new PodcastDatabase();
_logger.LogInformation("Loaded {Count} podcasts from database", _cache.Podcasts.Count);
return _cache;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load podcast database, starting fresh");
_cache = new PodcastDatabase();
return _cache;
}
}
private async Task SaveDatabaseInternalAsync(PodcastDatabase db)
{
try
{
var directory = Path.GetDirectoryName(DatabasePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
db.LastSaved = DateTime.UtcNow;
var json = JsonSerializer.Serialize(db, JsonOptions);
await File.WriteAllTextAsync(DatabasePath, json).ConfigureAwait(false);
_cache = db;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save podcast database");
throw;
}
}
private static string SanitizeFileName(string name)
{
var invalidChars = Path.GetInvalidFileNameChars();
var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray());
// Limit length
if (result.Length > 100)
{
result = result.Substring(0, 100);
}
return result.Trim();
}
private static string GetAudioExtension(string url)
{
try
{
var uri = new Uri(url);
var path = uri.AbsolutePath;
var extension = Path.GetExtension(path);
if (!string.IsNullOrEmpty(extension) &&
(extension.Equals(".mp3", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".m4a", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".ogg", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".opus", StringComparison.OrdinalIgnoreCase)))
{
return extension.ToLowerInvariant();
}
}
catch
{
// Ignore URL parsing errors
}
return ".mp3";
}
/// <inheritdoc />
public void Dispose()
{
_dbLock.Dispose();
}
}
@@ -0,0 +1,220 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.ServiceModel.Syndication;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using Jellyfin.Plugin.Jellypod.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for fetching and parsing podcast RSS feeds.
/// </summary>
public class RssFeedService : IRssFeedService
{
private static readonly XNamespace ItunesNs = "http://www.itunes.com/dtds/podcast-1.0.dtd";
private static readonly XNamespace ContentNs = "http://purl.org/rss/1.0/modules/content/";
private readonly ILogger<RssFeedService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="RssFeedService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
public RssFeedService(ILogger<RssFeedService> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
/// <inheritdoc />
public async Task<Podcast?> FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default)
{
try
{
var httpClient = _httpClientFactory.CreateClient("Jellypod");
using var response = await httpClient.GetAsync(feedUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = XmlReader.Create(stream);
var feed = SyndicationFeed.Load(reader);
var podcast = new Podcast
{
FeedUrl = feedUrl,
Title = feed.Title?.Text ?? "Unknown Podcast",
Description = StripHtml(feed.Description?.Text ?? string.Empty),
ImageUrl = GetItunesImage(feed) ?? feed.ImageUrl?.ToString(),
Author = GetItunesAuthor(feed),
Language = feed.Language,
LastUpdated = DateTime.UtcNow
};
// Parse episodes
foreach (var item in feed.Items)
{
var episode = ParseEpisode(item, podcast.Id);
if (episode != null)
{
podcast.Episodes.Add(episode);
}
}
_logger.LogInformation("Fetched podcast '{Title}' with {Count} episodes", podcast.Title, podcast.Episodes.Count);
return podcast;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch podcast from {FeedUrl}", feedUrl);
return null;
}
}
private Episode? ParseEpisode(SyndicationItem item, Guid podcastId)
{
// Find audio enclosure
var enclosure = item.Links.FirstOrDefault(l =>
string.Equals(l.RelationshipType, "enclosure", StringComparison.OrdinalIgnoreCase) &&
(l.MediaType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) == true ||
l.Uri?.ToString().EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) == true ||
l.Uri?.ToString().EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) == true));
if (enclosure == null)
{
return null;
}
return new Episode
{
PodcastId = podcastId,
Title = item.Title?.Text ?? "Untitled Episode",
Description = StripHtml(item.Summary?.Text ?? GetContentEncoded(item) ?? string.Empty),
AudioUrl = enclosure.Uri.ToString(),
FileSizeBytes = enclosure.Length > 0 ? enclosure.Length : null,
PublishedDate = item.PublishDate.UtcDateTime,
EpisodeGuid = item.Id ?? enclosure.Uri.ToString(),
Duration = GetItunesDuration(item),
SeasonNumber = GetItunesSeason(item),
EpisodeNumber = GetItunesEpisode(item),
ImageUrl = GetItunesEpisodeImage(item)
};
}
private static string? GetItunesImage(SyndicationFeed feed)
{
var imageElement = feed.ElementExtensions
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
if (imageElement != null)
{
var element = imageElement.GetObject<XElement>();
return element.Attribute("href")?.Value;
}
return null;
}
private static string? GetItunesAuthor(SyndicationFeed feed)
{
var authorElement = feed.ElementExtensions
.FirstOrDefault(e => e.OuterName == "author" && e.OuterNamespace == ItunesNs.NamespaceName);
return authorElement?.GetObject<XElement>()?.Value;
}
private static TimeSpan? GetItunesDuration(SyndicationItem item)
{
var durationElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "duration" && e.OuterNamespace == ItunesNs.NamespaceName);
if (durationElement == null)
{
return null;
}
var durationStr = durationElement.GetObject<XElement>()?.Value;
if (string.IsNullOrEmpty(durationStr))
{
return null;
}
// Duration can be in formats: HH:MM:SS, MM:SS, or just seconds
var parts = durationStr.Split(':');
return parts.Length switch
{
3 when int.TryParse(parts[0], out var h) && int.TryParse(parts[1], out var m) && int.TryParse(parts[2], out var s)
=> new TimeSpan(h, m, s),
2 when int.TryParse(parts[0], out var m) && int.TryParse(parts[1], out var s)
=> new TimeSpan(0, m, s),
1 when int.TryParse(parts[0], out var s)
=> TimeSpan.FromSeconds(s),
_ => null
};
}
private static int? GetItunesSeason(SyndicationItem item)
{
var seasonElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "season" && e.OuterNamespace == ItunesNs.NamespaceName);
var value = seasonElement?.GetObject<XElement>()?.Value;
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var season) ? season : null;
}
private static int? GetItunesEpisode(SyndicationItem item)
{
var episodeElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "episode" && e.OuterNamespace == ItunesNs.NamespaceName);
var value = episodeElement?.GetObject<XElement>()?.Value;
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var episode) ? episode : null;
}
private static string? GetItunesEpisodeImage(SyndicationItem item)
{
var imageElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
if (imageElement != null)
{
var element = imageElement.GetObject<XElement>();
return element.Attribute("href")?.Value;
}
return null;
}
private static string? GetContentEncoded(SyndicationItem item)
{
var contentElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "encoded" && e.OuterNamespace == ContentNs.NamespaceName);
return contentElement?.GetObject<XElement>()?.Value;
}
private static string StripHtml(string html)
{
if (string.IsNullOrEmpty(html))
{
return string.Empty;
}
// Simple HTML stripping - remove tags
var result = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", string.Empty);
// Decode common HTML entities
result = result.Replace("&nbsp;", " ", StringComparison.Ordinal)
.Replace("&amp;", "&", StringComparison.Ordinal)
.Replace("&lt;", "<", StringComparison.Ordinal)
.Replace("&gt;", ">", StringComparison.Ordinal)
.Replace("&quot;", "\"", StringComparison.Ordinal);
return result.Trim();
}
}