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 0000000..cacee0c Binary files /dev/null and b/Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg differ 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