221 lines
8.1 KiB
C#
221 lines
8.1 KiB
C#
using System;
|
|
using System.Globalization;
|
|
using System.Linq;
|
|
using System.Net.Http;
|
|
using System.ServiceModel.Syndication;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using System.Xml;
|
|
using System.Xml.Linq;
|
|
using Jellyfin.Plugin.Jellypod.Models;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.Jellypod.Services;
|
|
|
|
/// <summary>
|
|
/// Service for fetching and parsing podcast RSS feeds.
|
|
/// </summary>
|
|
public class RssFeedService : IRssFeedService
|
|
{
|
|
private static readonly XNamespace ItunesNs = "http://www.itunes.com/dtds/podcast-1.0.dtd";
|
|
private static readonly XNamespace ContentNs = "http://purl.org/rss/1.0/modules/content/";
|
|
|
|
private readonly ILogger<RssFeedService> _logger;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="RssFeedService"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">Logger instance.</param>
|
|
/// <param name="httpClientFactory">HTTP client factory.</param>
|
|
public RssFeedService(ILogger<RssFeedService> logger, IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_httpClientFactory = httpClientFactory;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<Podcast?> FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
var httpClient = _httpClientFactory.CreateClient("Jellypod");
|
|
using var response = await httpClient.GetAsync(feedUrl, cancellationToken).ConfigureAwait(false);
|
|
response.EnsureSuccessStatusCode();
|
|
|
|
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
|
|
using var reader = XmlReader.Create(stream);
|
|
var feed = SyndicationFeed.Load(reader);
|
|
|
|
var podcast = new Podcast
|
|
{
|
|
FeedUrl = feedUrl,
|
|
Title = feed.Title?.Text ?? "Unknown Podcast",
|
|
Description = StripHtml(feed.Description?.Text ?? string.Empty),
|
|
ImageUrl = GetItunesImage(feed) ?? feed.ImageUrl?.ToString(),
|
|
Author = GetItunesAuthor(feed),
|
|
Language = feed.Language,
|
|
LastUpdated = DateTime.UtcNow
|
|
};
|
|
|
|
// Parse episodes
|
|
foreach (var item in feed.Items)
|
|
{
|
|
var episode = ParseEpisode(item, podcast.Id);
|
|
if (episode != null)
|
|
{
|
|
podcast.Episodes.Add(episode);
|
|
}
|
|
}
|
|
|
|
_logger.LogInformation("Fetched podcast '{Title}' with {Count} episodes", podcast.Title, podcast.Episodes.Count);
|
|
return podcast;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to fetch podcast from {FeedUrl}", feedUrl);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private Episode? ParseEpisode(SyndicationItem item, Guid podcastId)
|
|
{
|
|
// Find audio enclosure
|
|
var enclosure = item.Links.FirstOrDefault(l =>
|
|
string.Equals(l.RelationshipType, "enclosure", StringComparison.OrdinalIgnoreCase) &&
|
|
(l.MediaType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) == true ||
|
|
l.Uri?.ToString().EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) == true ||
|
|
l.Uri?.ToString().EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) == true));
|
|
|
|
if (enclosure == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
return new Episode
|
|
{
|
|
PodcastId = podcastId,
|
|
Title = item.Title?.Text ?? "Untitled Episode",
|
|
Description = StripHtml(item.Summary?.Text ?? GetContentEncoded(item) ?? string.Empty),
|
|
AudioUrl = enclosure.Uri.ToString(),
|
|
FileSizeBytes = enclosure.Length > 0 ? enclosure.Length : null,
|
|
PublishedDate = item.PublishDate.UtcDateTime,
|
|
EpisodeGuid = item.Id ?? enclosure.Uri.ToString(),
|
|
Duration = GetItunesDuration(item),
|
|
SeasonNumber = GetItunesSeason(item),
|
|
EpisodeNumber = GetItunesEpisode(item),
|
|
ImageUrl = GetItunesEpisodeImage(item)
|
|
};
|
|
}
|
|
|
|
private static string? GetItunesImage(SyndicationFeed feed)
|
|
{
|
|
var imageElement = feed.ElementExtensions
|
|
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
|
|
|
|
if (imageElement != null)
|
|
{
|
|
var element = imageElement.GetObject<XElement>();
|
|
return element.Attribute("href")?.Value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? GetItunesAuthor(SyndicationFeed feed)
|
|
{
|
|
var authorElement = feed.ElementExtensions
|
|
.FirstOrDefault(e => e.OuterName == "author" && e.OuterNamespace == ItunesNs.NamespaceName);
|
|
|
|
return authorElement?.GetObject<XElement>()?.Value;
|
|
}
|
|
|
|
private static TimeSpan? GetItunesDuration(SyndicationItem item)
|
|
{
|
|
var durationElement = item.ElementExtensions
|
|
.FirstOrDefault(e => e.OuterName == "duration" && e.OuterNamespace == ItunesNs.NamespaceName);
|
|
|
|
if (durationElement == null)
|
|
{
|
|
return null;
|
|
}
|
|
|
|
var durationStr = durationElement.GetObject<XElement>()?.Value;
|
|
if (string.IsNullOrEmpty(durationStr))
|
|
{
|
|
return null;
|
|
}
|
|
|
|
// Duration can be in formats: HH:MM:SS, MM:SS, or just seconds
|
|
var parts = durationStr.Split(':');
|
|
return parts.Length switch
|
|
{
|
|
3 when int.TryParse(parts[0], out var h) && int.TryParse(parts[1], out var m) && int.TryParse(parts[2], out var s)
|
|
=> new TimeSpan(h, m, s),
|
|
2 when int.TryParse(parts[0], out var m) && int.TryParse(parts[1], out var s)
|
|
=> new TimeSpan(0, m, s),
|
|
1 when int.TryParse(parts[0], out var s)
|
|
=> TimeSpan.FromSeconds(s),
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static int? GetItunesSeason(SyndicationItem item)
|
|
{
|
|
var seasonElement = item.ElementExtensions
|
|
.FirstOrDefault(e => e.OuterName == "season" && e.OuterNamespace == ItunesNs.NamespaceName);
|
|
|
|
var value = seasonElement?.GetObject<XElement>()?.Value;
|
|
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var season) ? season : null;
|
|
}
|
|
|
|
private static int? GetItunesEpisode(SyndicationItem item)
|
|
{
|
|
var episodeElement = item.ElementExtensions
|
|
.FirstOrDefault(e => e.OuterName == "episode" && e.OuterNamespace == ItunesNs.NamespaceName);
|
|
|
|
var value = episodeElement?.GetObject<XElement>()?.Value;
|
|
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var episode) ? episode : null;
|
|
}
|
|
|
|
private static string? GetItunesEpisodeImage(SyndicationItem item)
|
|
{
|
|
var imageElement = item.ElementExtensions
|
|
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
|
|
|
|
if (imageElement != null)
|
|
{
|
|
var element = imageElement.GetObject<XElement>();
|
|
return element.Attribute("href")?.Value;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static string? GetContentEncoded(SyndicationItem item)
|
|
{
|
|
var contentElement = item.ElementExtensions
|
|
.FirstOrDefault(e => e.OuterName == "encoded" && e.OuterNamespace == ContentNs.NamespaceName);
|
|
|
|
return contentElement?.GetObject<XElement>()?.Value;
|
|
}
|
|
|
|
private static string StripHtml(string html)
|
|
{
|
|
if (string.IsNullOrEmpty(html))
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
// Simple HTML stripping - remove tags
|
|
var result = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", string.Empty);
|
|
// Decode common HTML entities
|
|
result = result.Replace(" ", " ", StringComparison.Ordinal)
|
|
.Replace("&", "&", StringComparison.Ordinal)
|
|
.Replace("<", "<", StringComparison.Ordinal)
|
|
.Replace(">", ">", StringComparison.Ordinal)
|
|
.Replace(""", "\"", StringComparison.Ordinal);
|
|
return result.Trim();
|
|
}
|
|
}
|