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