264 lines
8.7 KiB
C#
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), "...");
|
|
}
|
|
}
|