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(); } }