From 4679b77d1ad277aa33b3b003a9b5ee41cb7fa258 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 13 Dec 2025 23:57:58 +0100 Subject: [PATCH] First POC with podcasts library --- .vscode/settings.json | 2 +- ...mplate.sln => Jellyfin.Plugin.Jellypod.sln | 2 +- .../Api/JellypodController.cs | 365 ++++++++++++++ .../Api/Models/AddPodcastRequest.cs | 20 + .../Api/Models/DownloadEpisodesRequest.cs | 12 + .../Api/Models/DownloadResult.cs | 12 + .../Api/Models/PreviewFeedRequest.cs | 15 + .../Api/Models/UpdatePodcastRequest.cs | 17 + .../Api/StreamProxyController.cs | 85 ++++ .../Channels/JellypodChannel.cs | 472 ++++++++++++++++++ .../Configuration/PluginConfiguration.cs | 59 +++ .../Configuration/configPage.html | 383 ++++++++++++++ .../Images/channel-icon.jpg | Bin 0 -> 24187 bytes .../Jellyfin.Plugin.Jellypod.csproj | 11 +- Jellyfin.Plugin.Jellypod/Models/Episode.cs | 84 ++++ .../Models/EpisodeStatus.cs | 30 ++ Jellyfin.Plugin.Jellypod/Models/Podcast.cs | 77 +++ .../Models/PodcastDatabase.cs | 22 + .../Plugin.cs | 8 +- .../PluginServiceRegistrator.cs | 33 ++ .../ScheduledTasks/PodcastUpdateTask.cs | 142 ++++++ .../Services/IPodcastDownloadService.cs | 49 ++ .../Services/IPodcastStorageService.cs | 60 +++ .../Services/IRssFeedService.cs | 19 + .../Services/PodcastDownloadService.cs | 248 +++++++++ .../Services/PodcastStorageService.cs | 269 ++++++++++ .../Services/RssFeedService.cs | 220 ++++++++ .../Configuration/PluginConfiguration.cs | 57 --- .../Configuration/configPage.html | 79 --- Jellypod.html | 282 +++++++++++ build.yaml | 16 +- 31 files changed, 2998 insertions(+), 152 deletions(-) rename Jellyfin.Plugin.Template.sln => Jellyfin.Plugin.Jellypod.sln (92%) create mode 100644 Jellyfin.Plugin.Jellypod/Api/JellypodController.cs create mode 100644 Jellyfin.Plugin.Jellypod/Api/Models/AddPodcastRequest.cs create mode 100644 Jellyfin.Plugin.Jellypod/Api/Models/DownloadEpisodesRequest.cs create mode 100644 Jellyfin.Plugin.Jellypod/Api/Models/DownloadResult.cs create mode 100644 Jellyfin.Plugin.Jellypod/Api/Models/PreviewFeedRequest.cs create mode 100644 Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs create mode 100644 Jellyfin.Plugin.Jellypod/Api/StreamProxyController.cs create mode 100644 Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs create mode 100644 Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs create mode 100644 Jellyfin.Plugin.Jellypod/Configuration/configPage.html create mode 100644 Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg rename Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj => Jellyfin.Plugin.Jellypod/Jellyfin.Plugin.Jellypod.csproj (71%) create mode 100644 Jellyfin.Plugin.Jellypod/Models/Episode.cs create mode 100644 Jellyfin.Plugin.Jellypod/Models/EpisodeStatus.cs create mode 100644 Jellyfin.Plugin.Jellypod/Models/Podcast.cs create mode 100644 Jellyfin.Plugin.Jellypod/Models/PodcastDatabase.cs rename {Jellyfin.Plugin.Template => Jellyfin.Plugin.Jellypod}/Plugin.cs (86%) create mode 100644 Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs create mode 100644 Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs create mode 100644 Jellyfin.Plugin.Jellypod/Services/IPodcastDownloadService.cs create mode 100644 Jellyfin.Plugin.Jellypod/Services/IPodcastStorageService.cs create mode 100644 Jellyfin.Plugin.Jellypod/Services/IRssFeedService.cs create mode 100644 Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs create mode 100644 Jellyfin.Plugin.Jellypod/Services/PodcastStorageService.cs create mode 100644 Jellyfin.Plugin.Jellypod/Services/RssFeedService.cs delete mode 100644 Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs delete mode 100644 Jellyfin.Plugin.Template/Configuration/configPage.html create mode 100644 Jellypod.html diff --git a/.vscode/settings.json b/.vscode/settings.json index 7fa6075..fed612e 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -15,5 +15,5 @@ "jellyfinWindowsDataDir": "${env:LOCALAPPDATA}/jellyfin", "jellyfinLinuxDataDir": "$HOME/.local/share/jellyfin", // The name of the plugin - "pluginName": "Jellyfin.Plugin.Template", + "pluginName": "Jellyfin.Plugin.Jellypod", } \ No newline at end of file diff --git a/Jellyfin.Plugin.Template.sln b/Jellyfin.Plugin.Jellypod.sln similarity index 92% rename from Jellyfin.Plugin.Template.sln rename to Jellyfin.Plugin.Jellypod.sln index 7c9b9ee..eeec5ad 100644 --- a/Jellyfin.Plugin.Template.sln +++ b/Jellyfin.Plugin.Jellypod.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Template", "Jellyfin.Plugin.Template\Jellyfin.Plugin.Template.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.Jellypod", "Jellyfin.Plugin.Jellypod\Jellyfin.Plugin.Jellypod.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution diff --git a/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs new file mode 100644 index 0000000..4baa6dc --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs @@ -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; + +/// +/// Controller for Jellypod podcast management. +/// +[ApiController] +[Authorize] +[Route("Jellypod")] +[Produces(MediaTypeNames.Application.Json)] +public class JellypodController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IRssFeedService _rssFeedService; + private readonly IPodcastStorageService _storageService; + private readonly IPodcastDownloadService _downloadService; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance. + /// RSS feed service. + /// Storage service. + /// Download service. + public JellypodController( + ILogger logger, + IRssFeedService rssFeedService, + IPodcastStorageService storageService, + IPodcastDownloadService downloadService) + { + _logger = logger; + _rssFeedService = rssFeedService; + _storageService = storageService; + _downloadService = downloadService; + } + + /// + /// Gets all subscribed podcasts. + /// + /// List of podcasts. + [HttpGet("podcasts")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task>> GetPodcasts() + { + var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false); + return Ok(podcasts); + } + + /// + /// Gets a specific podcast by ID. + /// + /// Podcast ID. + /// The podcast. + [HttpGet("podcasts/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> GetPodcast([FromRoute] Guid id) + { + var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false); + return podcast != null ? Ok(podcast) : NotFound(); + } + + /// + /// Adds a new podcast subscription. + /// + /// Add podcast request. + /// The created podcast. + [HttpPost("podcasts")] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + public async Task> 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); + } + + /// + /// Deletes a podcast subscription. + /// + /// Podcast ID. + /// Whether to delete downloaded files. + /// No content. + [HttpDelete("podcasts/{id}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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(); + } + + /// + /// Updates a podcast's settings. + /// + /// Podcast ID. + /// Update request. + /// The updated podcast. + [HttpPut("podcasts/{id}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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); + } + + /// + /// Refreshes a podcast feed. + /// + /// Podcast ID. + /// The updated podcast. + [HttpPost("podcasts/{id}/refresh")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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); + } + + /// + /// Downloads multiple episodes from a podcast. + /// + /// Podcast ID. + /// Download request with count. + /// Number of episodes queued. + [HttpPost("podcasts/{id}/download")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task> 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 }); + } + + /// + /// Downloads a specific episode. + /// + /// Podcast ID. + /// Episode ID. + /// Accepted status. + [HttpPost("podcasts/{podcastId}/episodes/{episodeId}/download")] + [ProducesResponseType(StatusCodes.Status202Accepted)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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(); + } + + /// + /// Deletes an episode. + /// + /// Podcast ID. + /// Episode ID. + /// Whether to delete the downloaded file. + /// No content. + [HttpDelete("podcasts/{podcastId}/episodes/{episodeId}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task 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(); + } + + /// + /// Previews a podcast feed without subscribing. + /// + /// Preview request. + /// The podcast info. + [HttpPost("preview")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> 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"); + } + + /// + /// Removes duplicate podcast subscriptions, keeping the first one. + /// + /// Number of duplicates removed. + [HttpPost("cleanup")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task> CleanupDuplicates() + { + var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false); + var seenUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + var duplicatesToRemove = new List(); + + 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 }); + } +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/AddPodcastRequest.cs b/Jellyfin.Plugin.Jellypod/Api/Models/AddPodcastRequest.cs new file mode 100644 index 0000000..3e48edb --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/AddPodcastRequest.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Request to add a new podcast. +/// +public class AddPodcastRequest +{ + /// + /// Gets or sets the RSS feed URL. + /// + [Required] + public string FeedUrl { get; set; } = string.Empty; + + /// + /// Gets or sets whether to enable auto-download. + /// + public bool? AutoDownload { get; set; } +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/DownloadEpisodesRequest.cs b/Jellyfin.Plugin.Jellypod/Api/Models/DownloadEpisodesRequest.cs new file mode 100644 index 0000000..e98b602 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/DownloadEpisodesRequest.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Request to download episodes from a podcast. +/// +public class DownloadEpisodesRequest +{ + /// + /// Gets or sets the number of episodes to download. + /// + public int Count { get; set; } = 5; +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/DownloadResult.cs b/Jellyfin.Plugin.Jellypod/Api/Models/DownloadResult.cs new file mode 100644 index 0000000..7f7e447 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/DownloadResult.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Result of a download request. +/// +public class DownloadResult +{ + /// + /// Gets or sets the number of episodes queued for download. + /// + public int QueuedCount { get; set; } +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/PreviewFeedRequest.cs b/Jellyfin.Plugin.Jellypod/Api/Models/PreviewFeedRequest.cs new file mode 100644 index 0000000..1fd4d41 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/PreviewFeedRequest.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Request to preview a feed. +/// +public class PreviewFeedRequest +{ + /// + /// Gets or sets the RSS feed URL. + /// + [Required] + public string FeedUrl { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs b/Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs new file mode 100644 index 0000000..8c4cb04 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/UpdatePodcastRequest.cs @@ -0,0 +1,17 @@ +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Request to update a podcast. +/// +public class UpdatePodcastRequest +{ + /// + /// Gets or sets whether to enable auto-download. + /// + public bool? AutoDownload { get; set; } + + /// + /// Gets or sets the maximum episodes to keep. + /// + public int? MaxEpisodesToKeep { get; set; } +} diff --git a/Jellyfin.Plugin.Jellypod/Api/StreamProxyController.cs b/Jellyfin.Plugin.Jellypod/Api/StreamProxyController.cs new file mode 100644 index 0000000..75e6c29 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/StreamProxyController.cs @@ -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; + +/// +/// Controller for proxying podcast audio streams. +/// +[ApiController] +[Route("Jellypod/Stream")] +public class StreamProxyController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IHttpClientFactory _httpClientFactory; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance. + /// HTTP client factory. + public StreamProxyController( + ILogger logger, + IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + /// + /// Proxies an audio stream from a remote URL. + /// + /// Base64-encoded URL to stream. + /// Cancellation token. + /// The audio stream. + [HttpGet("{url}")] + [AllowAnonymous] + public async Task 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"); + } + } +} diff --git a/Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs b/Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs new file mode 100644 index 0000000..68c9b3e --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Channels/JellypodChannel.cs @@ -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; + +/// +/// Jellypod channel for browsing and playing podcast episodes. +/// +public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallback +{ + private readonly ILogger _logger; + private readonly IPodcastStorageService _storageService; + private readonly IRssFeedService _rssFeedService; + private readonly IPodcastDownloadService _downloadService; + private readonly IServerApplicationHost _appHost; + + /// + /// Initializes a new instance of the class. + /// + /// The logger factory. + /// The podcast storage service. + /// The RSS feed service. + /// The download service. + /// The server application host. + public JellypodChannel( + ILoggerFactory loggerFactory, + IPodcastStorageService storageService, + IRssFeedService rssFeedService, + IPodcastDownloadService downloadService, + IServerApplicationHost appHost) + { + _logger = loggerFactory.CreateLogger(); + _storageService = storageService; + _rssFeedService = rssFeedService; + _downloadService = downloadService; + _appHost = appHost; + _logger.LogDebug("JellypodChannel initialized"); + } + + /// + public string Name => "Podcasts"; + + /// + public string Description => "Browse and listen to your podcast subscriptions"; + + /// + public string DataVersion => "1.0"; + + /// + public string HomePageUrl => string.Empty; + + /// + public ChannelParentalRating ParentalRating => ChannelParentalRating.GeneralAudience; + + /// + public InternalChannelFeatures GetChannelFeatures() + { + return new InternalChannelFeatures + { + ContentTypes = new List + { + ChannelMediaContentType.Podcast + }, + MediaTypes = new List + { + ChannelMediaType.Audio + }, + SupportsSortOrderToggle = true, + DefaultSortFields = new List + { + ChannelItemSortField.PremiereDate, + ChannelItemSortField.DateCreated, + ChannelItemSortField.Name + }, + AutoRefreshLevels = 1, + MaxPageSize = 100 + }; + } + + /// + public Task 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 + }); + } + + /// + public IEnumerable GetSupportedChannelImages() + { + return new List + { + ImageType.Primary, + ImageType.Thumb + }; + } + + /// + public async Task 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(), TotalRecordCount = 0 }; + } + } + + private async Task> 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(); + } + + private async Task> GetPodcastFoldersAsync(CancellationToken cancellationToken) + { + var items = new List(); + 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> GetPodcastEpisodesAsync(Guid podcastId, CancellationToken cancellationToken) + { + var items = new List(); + 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 { 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 + } + + /// + 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); + } + + /// + public bool IsEnabledFor(string userId) + { + return true; + } + + /// + public async Task> 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(); + } + + // 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(); + } + + currentEpisode = reloadedEpisode; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to download episode: {Title}", currentEpisode.Title); + return Enumerable.Empty(); + } + } + + 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(); + } + + 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 { audioStream }, + DefaultAudioStreamIndex = 0, + ReadAtNativeFramerate = false + }; + } +} diff --git a/Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs new file mode 100644 index 0000000..c505b2a --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Configuration/PluginConfiguration.cs @@ -0,0 +1,59 @@ +using MediaBrowser.Model.Plugins; + +namespace Jellyfin.Plugin.Jellypod.Configuration; + +/// +/// Plugin configuration for Jellypod. +/// +public class PluginConfiguration : BasePluginConfiguration +{ + /// + /// Initializes a new instance of the class. + /// + public PluginConfiguration() + { + PodcastStoragePath = string.Empty; + UpdateIntervalHours = 6; + GlobalAutoDownloadEnabled = true; + MaxConcurrentDownloads = 2; + MaxEpisodesPerPodcast = 50; + CreatePodcastFolders = true; + DownloadNewEpisodesOnly = true; + } + + /// + /// Gets or sets the path where podcasts will be stored. + /// Leave empty to use the default Jellyfin data path. + /// + public string PodcastStoragePath { get; set; } + + /// + /// Gets or sets the update interval in hours for checking new episodes. + /// + public int UpdateIntervalHours { get; set; } + + /// + /// Gets or sets a value indicating whether auto-download is enabled globally. + /// + public bool GlobalAutoDownloadEnabled { get; set; } + + /// + /// Gets or sets the maximum number of concurrent downloads. + /// + public int MaxConcurrentDownloads { get; set; } + + /// + /// Gets or sets the maximum episodes to keep per podcast (0 = unlimited). + /// + public int MaxEpisodesPerPodcast { get; set; } + + /// + /// Gets or sets a value indicating whether to create subfolders for each podcast. + /// + public bool CreatePodcastFolders { get; set; } + + /// + /// Gets or sets a value indicating whether to only download new episodes after subscription. + /// + public bool DownloadNewEpisodesOnly { get; set; } +} diff --git a/Jellyfin.Plugin.Jellypod/Configuration/configPage.html b/Jellyfin.Plugin.Jellypod/Configuration/configPage.html new file mode 100644 index 0000000..24822b8 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Configuration/configPage.html @@ -0,0 +1,383 @@ + + + + + Jellypod + + + +
+
+
+

Jellypod Settings

+ +
+ Browse Podcasts: Your subscribed podcasts appear in the Channels section of Jellyfin's main menu. + Use this settings page to add/remove podcast subscriptions and configure download options. +
+ +
+ +
+

Storage

+
+ + +
+ Path where downloaded episodes are stored. Leave empty for default location. +
+
+
+ +
+
+ + +
+

Feed Updates

+
+ + +
+ How often to check for new episodes (default: 6 hours) +
+
+
+ + +
+

Downloads

+
+ +
+ Episodes can always be streamed directly. Enable this to also download them locally. +
+
+
+ + +
+
+ + +
+ Maximum episodes to keep downloaded per podcast (0 = unlimited) +
+
+
+ +
+ +
+
+ +
+ + +
+

Podcast Subscriptions

+ + +
+
+ + +
+ +
+ + +
+
+ No podcast subscriptions yet. Add one above, then browse in Channels. +
+
+
+
+
+ + +
+ + diff --git a/Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg b/Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..cacee0ca23af3ca77d1db2b48a45d844968953f2 GIT binary patch literal 24187 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 literal 0 HcmV?d00001 diff --git a/Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj b/Jellyfin.Plugin.Jellypod/Jellyfin.Plugin.Jellypod.csproj similarity index 71% rename from Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj rename to Jellyfin.Plugin.Jellypod/Jellyfin.Plugin.Jellypod.csproj index fd1cdb1..8bcfc41 100644 --- a/Jellyfin.Plugin.Template/Jellyfin.Plugin.Template.csproj +++ b/Jellyfin.Plugin.Jellypod/Jellyfin.Plugin.Jellypod.csproj @@ -1,22 +1,25 @@ - net9.0 - Jellyfin.Plugin.Template + net8.0 + Jellyfin.Plugin.Jellypod true true enable AllEnabledByDefault ../jellyfin.ruleset + true - + runtime runtime + + @@ -28,6 +31,8 @@ + + diff --git a/Jellyfin.Plugin.Jellypod/Models/Episode.cs b/Jellyfin.Plugin.Jellypod/Models/Episode.cs new file mode 100644 index 0000000..b51bca9 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Models/Episode.cs @@ -0,0 +1,84 @@ +using System; + +namespace Jellyfin.Plugin.Jellypod.Models; + +/// +/// Represents a podcast episode. +/// +public class Episode +{ + /// + /// Gets or sets the unique identifier for this episode. + /// + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Gets or sets the ID of the parent podcast. + /// + public Guid PodcastId { get; set; } + + /// + /// Gets or sets the episode title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the episode description. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the URL to the audio file. + /// + public string AudioUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the local file path where the episode is stored. + /// + public string? LocalFilePath { get; set; } + + /// + /// Gets or sets the file size in bytes. + /// + public long? FileSizeBytes { get; set; } + + /// + /// Gets or sets the episode duration. + /// + public TimeSpan? Duration { get; set; } + + /// + /// Gets or sets the publish date. + /// + public DateTime PublishedDate { get; set; } + + /// + /// Gets or sets the date the episode was downloaded. + /// + public DateTime? DownloadedDate { get; set; } + + /// + /// Gets or sets the download status. + /// + public EpisodeStatus Status { get; set; } = EpisodeStatus.Available; + + /// + /// Gets or sets the RSS GUID for deduplication. + /// + public string? EpisodeGuid { get; set; } + + /// + /// Gets or sets the season number if applicable. + /// + public int? SeasonNumber { get; set; } + + /// + /// Gets or sets the episode number if applicable. + /// + public int? EpisodeNumber { get; set; } + + /// + /// Gets or sets the episode-specific image URL. + /// + public string? ImageUrl { get; set; } +} diff --git a/Jellyfin.Plugin.Jellypod/Models/EpisodeStatus.cs b/Jellyfin.Plugin.Jellypod/Models/EpisodeStatus.cs new file mode 100644 index 0000000..b7ada2b --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Models/EpisodeStatus.cs @@ -0,0 +1,30 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.Jellypod.Models; + +/// +/// Represents the download status of a podcast episode. +/// +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum EpisodeStatus +{ + /// + /// Episode is known but not downloaded. + /// + Available, + + /// + /// Episode is currently being downloaded. + /// + Downloading, + + /// + /// Episode has been downloaded and is available locally. + /// + Downloaded, + + /// + /// Download failed. + /// + Error +} diff --git a/Jellyfin.Plugin.Jellypod/Models/Podcast.cs b/Jellyfin.Plugin.Jellypod/Models/Podcast.cs new file mode 100644 index 0000000..41652c8 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Models/Podcast.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Plugin.Jellypod.Models; + +/// +/// Represents a podcast subscription. +/// +public class Podcast +{ + /// + /// Gets or sets the unique identifier for this podcast. + /// + public Guid Id { get; set; } = Guid.NewGuid(); + + /// + /// Gets or sets the podcast title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets or sets the podcast description. + /// + public string Description { get; set; } = string.Empty; + + /// + /// Gets or sets the RSS feed URL. + /// + public string FeedUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the podcast artwork URL. + /// + public string? ImageUrl { get; set; } + + /// + /// Gets or sets the podcast author/publisher. + /// + public string? Author { get; set; } + + /// + /// Gets or sets the podcast language. + /// + public string? Language { get; set; } + + /// + /// Gets or sets the podcast category. + /// + public string? Category { get; set; } + + /// + /// Gets or sets the last time the feed was updated. + /// + public DateTime LastUpdated { get; set; } + + /// + /// Gets or sets the date this podcast was added. + /// + public DateTime DateAdded { get; set; } = DateTime.UtcNow; + + /// + /// Gets or sets a value indicating whether auto-download is enabled for this podcast. + /// + public bool AutoDownloadEnabled { get; set; } = true; + + /// + /// Gets or sets the maximum episodes to keep (0 = unlimited). + /// + public int MaxEpisodesToKeep { get; set; } + + /// + /// Gets or sets the list of episodes. + /// + [SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")] + public Collection Episodes { get; set; } = new(); +} diff --git a/Jellyfin.Plugin.Jellypod/Models/PodcastDatabase.cs b/Jellyfin.Plugin.Jellypod/Models/PodcastDatabase.cs new file mode 100644 index 0000000..05f3bb8 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Models/PodcastDatabase.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; + +namespace Jellyfin.Plugin.Jellypod.Models; + +/// +/// Container for podcast data persistence. +/// +public class PodcastDatabase +{ + /// + /// Gets or sets the list of subscribed podcasts. + /// + [SuppressMessage("Usage", "CA2227:Collection properties should be read only", Justification = "Required for JSON deserialization")] + public Collection Podcasts { get; set; } = new(); + + /// + /// Gets or sets the last time the database was saved. + /// + public DateTime LastSaved { get; set; } +} diff --git a/Jellyfin.Plugin.Template/Plugin.cs b/Jellyfin.Plugin.Jellypod/Plugin.cs similarity index 86% rename from Jellyfin.Plugin.Template/Plugin.cs rename to Jellyfin.Plugin.Jellypod/Plugin.cs index 445c2bc..22e48a3 100644 --- a/Jellyfin.Plugin.Template/Plugin.cs +++ b/Jellyfin.Plugin.Jellypod/Plugin.cs @@ -1,13 +1,13 @@ using System; using System.Collections.Generic; using System.Globalization; -using Jellyfin.Plugin.Template.Configuration; +using Jellyfin.Plugin.Jellypod.Configuration; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; -namespace Jellyfin.Plugin.Template; +namespace Jellyfin.Plugin.Jellypod; /// /// The main plugin. @@ -26,10 +26,10 @@ public class Plugin : BasePlugin, IHasWebPages } /// - public override string Name => "Template"; + public override string Name => "Jellypod"; /// - public override Guid Id => Guid.Parse("eb5d7894-8eef-4b36-aa6f-5d124e828ce1"); + public override Guid Id => Guid.Parse("c713faf4-4e50-4e87-941a-1200178ed605"); /// /// Gets the current plugin instance. diff --git a/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs b/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs new file mode 100644 index 0000000..07478de --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs @@ -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; + +/// +/// Registers plugin services with the dependency injection container. +/// +public class PluginServiceRegistrator : IPluginServiceRegistrator +{ + /// + 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(); + serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); + + // Register channel + serviceCollection.AddSingleton(); + } +} diff --git a/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs b/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs new file mode 100644 index 0000000..409f454 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/ScheduledTasks/PodcastUpdateTask.cs @@ -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; + +/// +/// Scheduled task that updates all podcast feeds and downloads new episodes. +/// +public class PodcastUpdateTask : IScheduledTask +{ + private readonly ILogger _logger; + private readonly IRssFeedService _rssFeedService; + private readonly IPodcastStorageService _storageService; + private readonly IPodcastDownloadService _downloadService; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance. + /// RSS feed service. + /// Storage service. + /// Download service. + public PodcastUpdateTask( + ILogger logger, + IRssFeedService rssFeedService, + IPodcastStorageService storageService, + IPodcastDownloadService downloadService) + { + _logger = logger; + _rssFeedService = rssFeedService; + _storageService = storageService; + _downloadService = downloadService; + } + + /// + public string Name => "Update Podcast Feeds"; + + /// + public string Key => "JellypodUpdateFeeds"; + + /// + public string Description => "Checks all subscribed podcasts for new episodes and downloads them."; + + /// + public string Category => "Jellypod"; + + /// + public async Task ExecuteAsync(IProgress 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"); + } + + /// + public IEnumerable GetDefaultTriggers() + { + var config = Plugin.Instance?.Configuration; + var intervalHours = config?.UpdateIntervalHours ?? 6; + + return new[] + { + new TaskTriggerInfo + { + Type = TaskTriggerInfo.TriggerInterval, + IntervalTicks = TimeSpan.FromHours(intervalHours).Ticks + } + }; + } +} diff --git a/Jellyfin.Plugin.Jellypod/Services/IPodcastDownloadService.cs b/Jellyfin.Plugin.Jellypod/Services/IPodcastDownloadService.cs new file mode 100644 index 0000000..108d96c --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/IPodcastDownloadService.cs @@ -0,0 +1,49 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Jellypod.Models; + +namespace Jellyfin.Plugin.Jellypod.Services; + +/// +/// Service for downloading podcast episodes. +/// +public interface IPodcastDownloadService +{ + /// + /// Queues an episode for download. + /// + /// The podcast. + /// The episode to download. + /// Task representing the queue operation. + Task QueueDownloadAsync(Podcast podcast, Episode episode); + + /// + /// Downloads an episode immediately. + /// + /// The podcast. + /// The episode to download. + /// Optional progress reporter. + /// Cancellation token. + /// The local file path of the downloaded episode. + Task DownloadEpisodeAsync( + Podcast podcast, + Episode episode, + IProgress? progress = null, + CancellationToken cancellationToken = default); + + /// + /// Deletes a downloaded episode file. + /// + /// The episode whose file to delete. + /// Task representing the delete operation. + Task DeleteEpisodeFileAsync(Episode episode); + + /// + /// Downloads podcast artwork. + /// + /// The podcast. + /// Cancellation token. + /// Task representing the download operation. + Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default); +} diff --git a/Jellyfin.Plugin.Jellypod/Services/IPodcastStorageService.cs b/Jellyfin.Plugin.Jellypod/Services/IPodcastStorageService.cs new file mode 100644 index 0000000..cb01e09 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/IPodcastStorageService.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Jellyfin.Plugin.Jellypod.Models; + +namespace Jellyfin.Plugin.Jellypod.Services; + +/// +/// Service for storing and retrieving podcast data. +/// +public interface IPodcastStorageService +{ + /// + /// Gets all subscribed podcasts. + /// + /// List of all podcasts. + Task> GetAllPodcastsAsync(); + + /// + /// Gets a podcast by its ID. + /// + /// The podcast ID. + /// The podcast, or null if not found. + Task GetPodcastAsync(Guid id); + + /// + /// Adds a new podcast subscription. + /// + /// The podcast to add. + /// Task representing the operation. + Task AddPodcastAsync(Podcast podcast); + + /// + /// Updates an existing podcast. + /// + /// The podcast to update. + /// Task representing the operation. + Task UpdatePodcastAsync(Podcast podcast); + + /// + /// Deletes a podcast subscription. + /// + /// The podcast ID to delete. + /// Task representing the operation. + Task DeletePodcastAsync(Guid id); + + /// + /// Gets the local file path for an episode. + /// + /// The parent podcast. + /// The episode. + /// The file path where the episode should be stored. + string GetEpisodeFilePath(Podcast podcast, Episode episode); + + /// + /// Gets the storage path for podcasts. + /// + /// The base path for podcast storage. + string GetStoragePath(); +} diff --git a/Jellyfin.Plugin.Jellypod/Services/IRssFeedService.cs b/Jellyfin.Plugin.Jellypod/Services/IRssFeedService.cs new file mode 100644 index 0000000..2da5996 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/IRssFeedService.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; +using Jellyfin.Plugin.Jellypod.Models; + +namespace Jellyfin.Plugin.Jellypod.Services; + +/// +/// Service for fetching and parsing podcast RSS feeds. +/// +public interface IRssFeedService +{ + /// + /// Fetches and parses a podcast from an RSS feed URL. + /// + /// The RSS feed URL. + /// Cancellation token. + /// The parsed podcast with episodes, or null if parsing failed. + Task FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default); +} diff --git a/Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs b/Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs new file mode 100644 index 0000000..d6c27d0 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/PodcastDownloadService.cs @@ -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; + +/// +/// Service for downloading podcast episodes. +/// +public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposable +{ + private readonly ILogger _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; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance. + /// HTTP client factory. + /// Storage service. + public PodcastDownloadService( + ILogger logger, + IHttpClientFactory httpClientFactory, + IPodcastStorageService storageService) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + _storageService = storageService; + + var maxConcurrent = Plugin.Instance?.Configuration?.MaxConcurrentDownloads ?? 2; + _downloadSemaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent); + } + + /// + 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; + } + + /// + public async Task DownloadEpisodeAsync( + Podcast podcast, + Episode episode, + IProgress? 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; + } + } + + /// + 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; + } + + /// + 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(); + } + + /// + public void Dispose() + { + _downloadSemaphore.Dispose(); + } +} diff --git a/Jellyfin.Plugin.Jellypod/Services/PodcastStorageService.cs b/Jellyfin.Plugin.Jellypod/Services/PodcastStorageService.cs new file mode 100644 index 0000000..249f2c4 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/PodcastStorageService.cs @@ -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; + +/// +/// Service for storing and retrieving podcast data. +/// +public sealed class PodcastStorageService : IPodcastStorageService, IDisposable +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + private readonly ILogger _logger; + private readonly IApplicationPaths _applicationPaths; + private readonly SemaphoreSlim _dbLock = new(1, 1); + private PodcastDatabase? _cache; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance. + /// Application paths. + public PodcastStorageService(ILogger 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"); + + /// + public async Task> GetAllPodcastsAsync() + { + var db = await LoadDatabaseAsync().ConfigureAwait(false); + return db.Podcasts.ToList(); + } + + /// + public async Task GetPodcastAsync(Guid id) + { + var db = await LoadDatabaseAsync().ConfigureAwait(false); + return db.Podcasts.FirstOrDefault(p => p.Id == id); + } + + /// + 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(); + } + } + + /// + 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(); + } + } + + /// + 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(); + } + } + + /// + 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}"); + } + + /// + 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 LoadDatabaseAsync() + { + await _dbLock.WaitAsync().ConfigureAwait(false); + try + { + return await LoadDatabaseInternalAsync().ConfigureAwait(false); + } + finally + { + _dbLock.Release(); + } + } + + private async Task 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(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"; + } + + /// + public void Dispose() + { + _dbLock.Dispose(); + } +} diff --git a/Jellyfin.Plugin.Jellypod/Services/RssFeedService.cs b/Jellyfin.Plugin.Jellypod/Services/RssFeedService.cs new file mode 100644 index 0000000..5e7cc04 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/RssFeedService.cs @@ -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; + +/// +/// Service for fetching and parsing podcast RSS feeds. +/// +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 _logger; + private readonly IHttpClientFactory _httpClientFactory; + + /// + /// Initializes a new instance of the class. + /// + /// Logger instance. + /// HTTP client factory. + public RssFeedService(ILogger logger, IHttpClientFactory httpClientFactory) + { + _logger = logger; + _httpClientFactory = httpClientFactory; + } + + /// + public async Task 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(); + 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()?.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()?.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()?.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()?.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(); + 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()?.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(" ", " ", StringComparison.Ordinal) + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace(""", "\"", StringComparison.Ordinal); + return result.Trim(); + } +} diff --git a/Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs deleted file mode 100644 index 564e6bf..0000000 --- a/Jellyfin.Plugin.Template/Configuration/PluginConfiguration.cs +++ /dev/null @@ -1,57 +0,0 @@ -using MediaBrowser.Model.Plugins; - -namespace Jellyfin.Plugin.Template.Configuration; - -/// -/// The configuration options. -/// -public enum SomeOptions -{ - /// - /// Option one. - /// - OneOption, - - /// - /// Second option. - /// - AnotherOption -} - -/// -/// Plugin configuration. -/// -public class PluginConfiguration : BasePluginConfiguration -{ - /// - /// Initializes a new instance of the class. - /// - public PluginConfiguration() - { - // set default options here - Options = SomeOptions.AnotherOption; - TrueFalseSetting = true; - AnInteger = 2; - AString = "string"; - } - - /// - /// Gets or sets a value indicating whether some true or false setting is enabled.. - /// - public bool TrueFalseSetting { get; set; } - - /// - /// Gets or sets an integer setting. - /// - public int AnInteger { get; set; } - - /// - /// Gets or sets a string setting. - /// - public string AString { get; set; } - - /// - /// Gets or sets an enum option. - /// - public SomeOptions Options { get; set; } -} diff --git a/Jellyfin.Plugin.Template/Configuration/configPage.html b/Jellyfin.Plugin.Template/Configuration/configPage.html deleted file mode 100644 index 23f024e..0000000 --- a/Jellyfin.Plugin.Template/Configuration/configPage.html +++ /dev/null @@ -1,79 +0,0 @@ - - - - - Template - - -
-
-
-
-
- - -
-
- - -
A Description
-
-
- -
-
- - -
Another Description
-
-
- -
-
-
-
- -
- - diff --git a/Jellypod.html b/Jellypod.html new file mode 100644 index 0000000..f20f42f --- /dev/null +++ b/Jellypod.html @@ -0,0 +1,282 @@ + + +Jellypod
+
+
+

Jellypod Settings

+ +
+ Browse Podcasts: Your subscribed podcasts appear in the Channels section of Jellyfin's main menu. + Use this settings page to add/remove podcast subscriptions and configure download options. +
+ +
+ +
+

Storage

+
+ + +
+ Path where downloaded episodes are stored. Leave empty for default location. +
+
+
+ +
+
+ + +
+

Feed Updates

+
+ + +
+ How often to check for new episodes (default: 6 hours) +
+
+
+ + +
+

Downloads

+
+ +
+ Episodes can always be streamed directly. Enable this to also download them locally. +
+
+
+ + +
+
+ + +
+ Maximum episodes to keep downloaded per podcast (0 = unlimited) +
+
+
+ +
+ +
+
+ +
+ + +
+

Podcast Subscriptions

+ + +
+
+ + +
+ +
+ + +
+ +
+
+
+
+ + +
\ No newline at end of file diff --git a/build.yaml b/build.yaml index 168daa7..b1bcb27 100644 --- a/build.yaml +++ b/build.yaml @@ -1,16 +1,18 @@ --- -name: "Template" -guid: "eb5d7894-8eef-4b36-aa6f-5d124e828ce1" +name: "Jellypod" +guid: "c713faf4-4e50-4e87-941a-1200178ed605" version: "1.0.0.0" targetAbi: "10.9.0.0" framework: "net8.0" -overview: "Short description about your plugin" +overview: "Podcast management plugin for Jellyfin" description: > - This is a longer description that can span more than one - line and include details about your plugin. + Jellypod allows you to subscribe to podcast RSS feeds, automatically download + episodes, and manage your podcast library within Jellyfin. Episodes are stored + as standard audio files and integrate with Jellyfin's built-in audio player. category: "General" owner: "jellyfin" artifacts: -- "Jellyfin.Plugin.Template.dll" +- "Jellyfin.Plugin.Jellypod.dll" +- "System.ServiceModel.Syndication.dll" changelog: > - changelog + Initial release