Added OPML support
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Jellypod.Api.Models;
|
||||
using Jellyfin.Plugin.Jellypod.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.Jellypod.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Service for handling OPML import and export.
|
||||
/// </summary>
|
||||
public interface IOpmlService
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses OPML content and extracts podcast outlines.
|
||||
/// </summary>
|
||||
/// <param name="opmlContent">The OPML XML content.</param>
|
||||
/// <returns>List of parsed outlines.</returns>
|
||||
IReadOnlyList<OpmlOutline> ParseOpml(string opmlContent);
|
||||
|
||||
/// <summary>
|
||||
/// Parses OPML from a stream.
|
||||
/// </summary>
|
||||
/// <param name="stream">The input stream containing OPML XML.</param>
|
||||
/// <returns>List of parsed outlines.</returns>
|
||||
IReadOnlyList<OpmlOutline> ParseOpml(Stream stream);
|
||||
|
||||
/// <summary>
|
||||
/// Imports podcasts from parsed OPML outlines.
|
||||
/// </summary>
|
||||
/// <param name="outlines">The parsed OPML outlines.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Import result with statistics.</returns>
|
||||
Task<OpmlImportResult> ImportPodcastsAsync(
|
||||
IReadOnlyList<OpmlOutline> outlines,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Exports all subscribed podcasts to OPML format.
|
||||
/// </summary>
|
||||
/// <returns>OPML XML string.</returns>
|
||||
Task<string> ExportToOpmlAsync();
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
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), "...");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user