Duncan Tourolle 003a8754a6
All checks were successful
🚀 Release Plugin / build-and-release (push) Successful in 2m3s
🏗️ Build Plugin / build (push) Successful in 2m0s
🧪 Test Plugin / test (push) Successful in 59s
Added OPML support
2025-12-16 20:31:46 +01:00

264 lines
8.7 KiB
C#

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;
/// <summary>
/// Service for handling OPML import and export operations.
/// </summary>
public class OpmlService : IOpmlService
{
private readonly ILogger<OpmlService> _logger;
private readonly IRssFeedService _rssFeedService;
private readonly IPodcastStorageService _storageService;
/// <summary>
/// Initializes a new instance of the <see cref="OpmlService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="rssFeedService">RSS feed service.</param>
/// <param name="storageService">Storage service.</param>
public OpmlService(
ILogger<OpmlService> logger,
IRssFeedService rssFeedService,
IPodcastStorageService storageService)
{
_logger = logger;
_rssFeedService = rssFeedService;
_storageService = storageService;
}
/// <inheritdoc />
public IReadOnlyList<OpmlOutline> ParseOpml(string opmlContent)
{
using var reader = new StringReader(opmlContent);
var doc = XDocument.Load(reader);
return ParseOpmlDocument(doc);
}
/// <inheritdoc />
public IReadOnlyList<OpmlOutline> ParseOpml(Stream stream)
{
var doc = XDocument.Load(stream);
return ParseOpmlDocument(doc);
}
private List<OpmlOutline> ParseOpmlDocument(XDocument doc)
{
var outlines = new List<OpmlOutline>();
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<XElement> elements,
List<OpmlOutline> 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);
}
}
}
/// <inheritdoc />
public async Task<OpmlImportResult> ImportPodcastsAsync(
IReadOnlyList<OpmlOutline> 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;
}
/// <inheritdoc />
public async Task<string> 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), "...");
}
}