using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; using System.Xml.Linq; using Jellyfin.Plugin.Jellypod.Api.Models; using Jellyfin.Plugin.Jellypod.Models; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Jellypod.Services; /// /// Service for handling OPML import and export operations. /// public class OpmlService : IOpmlService { private readonly ILogger _logger; private readonly IRssFeedService _rssFeedService; private readonly IPodcastStorageService _storageService; /// /// Initializes a new instance of the class. /// /// Logger instance. /// RSS feed service. /// Storage service. public OpmlService( ILogger logger, IRssFeedService rssFeedService, IPodcastStorageService storageService) { _logger = logger; _rssFeedService = rssFeedService; _storageService = storageService; } /// public IReadOnlyList ParseOpml(string opmlContent) { using var reader = new StringReader(opmlContent); var doc = XDocument.Load(reader); return ParseOpmlDocument(doc); } /// public IReadOnlyList ParseOpml(Stream stream) { var doc = XDocument.Load(stream); return ParseOpmlDocument(doc); } private List ParseOpmlDocument(XDocument doc) { var outlines = new List(); var body = doc.Root?.Element("body"); if (body == null) { _logger.LogWarning("OPML document has no body element"); return outlines; } // Recursively find all outline elements with xmlUrl (podcast feeds) ParseOutlines(body.Elements("outline"), outlines, null); _logger.LogInformation("Parsed {Count} podcast feeds from OPML", outlines.Count); return outlines; } private void ParseOutlines( IEnumerable elements, List outlines, string? parentCategory) { foreach (var element in elements) { var xmlUrl = element.Attribute("xmlUrl")?.Value; var text = element.Attribute("text")?.Value; if (!string.IsNullOrEmpty(xmlUrl)) { // This is a feed outline outlines.Add(new OpmlOutline { Text = text, Title = element.Attribute("title")?.Value, XmlUrl = xmlUrl, HtmlUrl = element.Attribute("htmlUrl")?.Value, Description = element.Attribute("description")?.Value, Category = parentCategory ?? element.Attribute("category")?.Value }); } else if (element.HasElements) { // This might be a category/folder - use text as category for children var category = parentCategory ?? text; ParseOutlines(element.Elements("outline"), outlines, category); } } } /// public async Task ImportPodcastsAsync( IReadOnlyList outlines, CancellationToken cancellationToken = default) { var result = new OpmlImportResult { TotalFeeds = outlines.Count }; // Get existing feeds to check for duplicates var existingPodcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false); var existingUrls = existingPodcasts .Select(p => p.FeedUrl) .ToHashSet(StringComparer.OrdinalIgnoreCase); foreach (var outline in outlines) { if (cancellationToken.IsCancellationRequested) { break; } if (string.IsNullOrWhiteSpace(outline.XmlUrl)) { continue; } // Check if already subscribed if (existingUrls.Contains(outline.XmlUrl)) { result.SkippedCount++; result.SkippedFeeds.Add(outline.XmlUrl); _logger.LogDebug("Skipping already subscribed feed: {Url}", outline.XmlUrl); continue; } try { var podcast = await _rssFeedService.FetchPodcastAsync( outline.XmlUrl, cancellationToken).ConfigureAwait(false); if (podcast == null) { result.FailedCount++; result.Errors.Add(new OpmlImportError { FeedUrl = outline.XmlUrl, Title = outline.DisplayTitle, Error = "Failed to fetch or parse RSS feed" }); continue; } // Preserve category from OPML if the feed doesn't have one if (string.IsNullOrEmpty(podcast.Category) && !string.IsNullOrEmpty(outline.Category)) { podcast.Category = outline.Category; } await _storageService.AddPodcastAsync(podcast).ConfigureAwait(false); result.ImportedCount++; result.ImportedPodcasts.Add(podcast.Title); existingUrls.Add(outline.XmlUrl); // Prevent duplicate adds within same import _logger.LogInformation("Imported podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl); } catch (Exception ex) { result.FailedCount++; result.Errors.Add(new OpmlImportError { FeedUrl = outline.XmlUrl, Title = outline.DisplayTitle, Error = ex.Message }); _logger.LogWarning(ex, "Failed to import feed: {Url}", outline.XmlUrl); } } _logger.LogInformation( "OPML import complete: {Imported} imported, {Skipped} skipped, {Failed} failed out of {Total}", result.ImportedCount, result.SkippedCount, result.FailedCount, result.TotalFeeds); return result; } /// public async Task ExportToOpmlAsync() { var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false); var doc = new XDocument( new XDeclaration("1.0", "utf-8", null), new XElement( "opml", new XAttribute("version", "2.0"), new XElement( "head", new XElement("title", "Jellypod Podcast Subscriptions"), new XElement("dateCreated", DateTime.UtcNow.ToString("r", CultureInfo.InvariantCulture)), new XElement("docs", "http://opml.org/spec2.opml")), new XElement( "body", podcasts.Select(p => CreateOutlineElement(p))))); var settings = new XmlWriterSettings { Indent = true, Encoding = new UTF8Encoding(false), OmitXmlDeclaration = false }; using var stringWriter = new StringWriter(); using (var xmlWriter = XmlWriter.Create(stringWriter, settings)) { doc.Save(xmlWriter); } _logger.LogInformation("Exported {Count} podcasts to OPML", podcasts.Count); return stringWriter.ToString(); } private static XElement CreateOutlineElement(Podcast podcast) { var element = new XElement( "outline", new XAttribute("type", "rss"), new XAttribute("text", podcast.Title), new XAttribute("title", podcast.Title), new XAttribute("xmlUrl", podcast.FeedUrl)); if (!string.IsNullOrEmpty(podcast.Description)) { element.Add(new XAttribute("description", TruncateDescription(podcast.Description))); } if (!string.IsNullOrEmpty(podcast.Category)) { element.Add(new XAttribute("category", podcast.Category)); } return element; } private static string TruncateDescription(string description, int maxLength = 200) { if (string.IsNullOrEmpty(description) || description.Length <= maxLength) { return description; } return string.Concat(description.AsSpan(0, maxLength), "..."); } }