diff --git a/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs index 93dd4e4..5cac732 100644 --- a/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs +++ b/Jellyfin.Plugin.Jellypod/Api/JellypodController.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Net.Http; using System.Net.Mime; using System.Threading.Tasks; using Jellyfin.Plugin.Jellypod.Api.Models; @@ -26,6 +27,8 @@ public class JellypodController : ControllerBase private readonly IRssFeedService _rssFeedService; private readonly IPodcastStorageService _storageService; private readonly IPodcastDownloadService _downloadService; + private readonly IOpmlService _opmlService; + private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. @@ -34,16 +37,22 @@ public class JellypodController : ControllerBase /// RSS feed service. /// Storage service. /// Download service. + /// OPML service. + /// HTTP client factory. public JellypodController( ILogger logger, IRssFeedService rssFeedService, IPodcastStorageService storageService, - IPodcastDownloadService downloadService) + IPodcastDownloadService downloadService, + IOpmlService opmlService, + IHttpClientFactory httpClientFactory) { _logger = logger; _rssFeedService = rssFeedService; _storageService = storageService; _downloadService = downloadService; + _opmlService = opmlService; + _httpClientFactory = httpClientFactory; } /// @@ -474,4 +483,102 @@ public class JellypodController : ControllerBase PlayCount = episode.PlayCount }); } + + /// + /// Exports all podcast subscriptions as OPML. + /// + /// OPML XML file. + [HttpGet("opml/export")] + [Produces("application/xml")] + [ProducesResponseType(StatusCodes.Status200OK)] + public async Task ExportOpml() + { + var opml = await _opmlService.ExportToOpmlAsync().ConfigureAwait(false); + var bytes = System.Text.Encoding.UTF8.GetBytes(opml); + + var fileName = $"jellypod-subscriptions-{DateTime.UtcNow:yyyy-MM-dd}.opml"; + return File(bytes, "application/xml", fileName); + } + + /// + /// Imports podcasts from an uploaded OPML file. + /// + /// The OPML file to import. + /// Import results. + [HttpPost("opml/import")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> ImportOpmlFile(IFormFile file) + { + if (file == null || file.Length == 0) + { + return BadRequest("No file uploaded"); + } + + if (file.Length > 5 * 1024 * 1024) // 5MB limit + { + return BadRequest("File too large. Maximum size is 5MB."); + } + + try + { + using var stream = file.OpenReadStream(); + var outlines = _opmlService.ParseOpml(stream); + + if (outlines.Count == 0) + { + return BadRequest("No podcast feeds found in OPML file"); + } + + var result = await _opmlService.ImportPodcastsAsync(outlines).ConfigureAwait(false); + return Ok(result); + } + catch (System.Xml.XmlException ex) + { + _logger.LogWarning(ex, "Invalid OPML XML"); + return BadRequest("Invalid OPML file format"); + } + } + + /// + /// Imports podcasts from an OPML URL. + /// + /// Request containing the OPML URL. + /// Import results. + [HttpPost("opml/import-url")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + public async Task> ImportOpmlUrl([FromBody] OpmlImportRequest request) + { + if (string.IsNullOrWhiteSpace(request.Url)) + { + return BadRequest("URL is required"); + } + + try + { + var httpClient = _httpClientFactory.CreateClient("Jellypod"); + var opmlContent = await httpClient.GetStringAsync(request.Url).ConfigureAwait(false); + + var outlines = _opmlService.ParseOpml(opmlContent); + + if (outlines.Count == 0) + { + return BadRequest("No podcast feeds found in OPML"); + } + + var result = await _opmlService.ImportPodcastsAsync(outlines).ConfigureAwait(false); + return Ok(result); + } + catch (HttpRequestException ex) + { + _logger.LogWarning(ex, "Failed to fetch OPML from URL"); + return BadRequest("Failed to fetch OPML from URL"); + } + catch (System.Xml.XmlException ex) + { + _logger.LogWarning(ex, "Invalid OPML XML from URL"); + return BadRequest("Invalid OPML format"); + } + } } diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportError.cs b/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportError.cs new file mode 100644 index 0000000..2e29ad2 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportError.cs @@ -0,0 +1,22 @@ +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Details about a failed OPML feed import. +/// +public class OpmlImportError +{ + /// + /// Gets or sets the feed URL that failed. + /// + public string FeedUrl { get; set; } = string.Empty; + + /// + /// Gets or sets the feed title from OPML (if available). + /// + public string? Title { get; set; } + + /// + /// Gets or sets the error message. + /// + public string Error { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportRequest.cs b/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportRequest.cs new file mode 100644 index 0000000..185a9f2 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportRequest.cs @@ -0,0 +1,12 @@ +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Request to import podcasts from an OPML URL. +/// +public class OpmlImportRequest +{ + /// + /// Gets or sets the URL to fetch OPML from. + /// + public string Url { get; set; } = string.Empty; +} diff --git a/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportResult.cs b/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportResult.cs new file mode 100644 index 0000000..4f70474 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Api/Models/OpmlImportResult.cs @@ -0,0 +1,44 @@ +using System.Collections.ObjectModel; + +namespace Jellyfin.Plugin.Jellypod.Api.Models; + +/// +/// Result of an OPML import operation. +/// +public class OpmlImportResult +{ + /// + /// Gets or sets the total number of feeds found in the OPML. + /// + public int TotalFeeds { get; set; } + + /// + /// Gets or sets the number of feeds successfully imported. + /// + public int ImportedCount { get; set; } + + /// + /// Gets or sets the number of feeds skipped (already subscribed). + /// + public int SkippedCount { get; set; } + + /// + /// Gets or sets the number of feeds that failed to import. + /// + public int FailedCount { get; set; } + + /// + /// Gets the list of imported podcast titles. + /// + public Collection ImportedPodcasts { get; } = new(); + + /// + /// Gets the list of skipped feed URLs (already subscribed). + /// + public Collection SkippedFeeds { get; } = new(); + + /// + /// Gets the list of failed imports with error details. + /// + public Collection Errors { get; } = new(); +} diff --git a/Jellyfin.Plugin.Jellypod/Configuration/configPage.html b/Jellyfin.Plugin.Jellypod/Configuration/configPage.html index 93364ff..1d54a6d 100644 --- a/Jellyfin.Plugin.Jellypod/Configuration/configPage.html +++ b/Jellyfin.Plugin.Jellypod/Configuration/configPage.html @@ -179,6 +179,19 @@

Podcast Subscriptions

+ +
+ + + +
+
@@ -410,6 +423,102 @@ addPodcast(); } }); + + // Export OPML + document.querySelector('#btnExportOpml').addEventListener('click', function() { + Dashboard.showLoadingMsg(); + + fetch(ApiClient.getUrl('Jellypod/opml/export'), { + method: 'GET', + headers: { + 'Authorization': ApiClient.accessToken() ? ('MediaBrowser Token="' + ApiClient.accessToken() + '"') : '' + } + }) + .then(function(response) { + if (!response.ok) { + throw new Error('Export failed'); + } + return response.blob(); + }) + .then(function(blob) { + Dashboard.hideLoadingMsg(); + + // Create download link + var url = window.URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'jellypod-subscriptions-' + new Date().toISOString().split('T')[0] + '.opml'; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + document.body.removeChild(a); + }) + .catch(function(err) { + Dashboard.hideLoadingMsg(); + Dashboard.alert('Export failed: ' + err.message); + }); + }); + + // Import OPML - trigger file picker + document.querySelector('#btnImportOpml').addEventListener('click', function() { + document.querySelector('#opmlFileInput').click(); + }); + + // Handle file selection + document.querySelector('#opmlFileInput').addEventListener('change', function(e) { + var file = e.target.files[0]; + if (!file) return; + + // Reset input so same file can be selected again + e.target.value = ''; + + Dashboard.showLoadingMsg(); + + var formData = new FormData(); + formData.append('file', file); + + // Use fetch for multipart/form-data upload + fetch(ApiClient.getUrl('Jellypod/opml/import'), { + method: 'POST', + headers: { + 'Authorization': ApiClient.accessToken() ? ('MediaBrowser Token="' + ApiClient.accessToken() + '"') : '' + }, + body: formData + }) + .then(function(response) { + if (!response.ok) { + return response.text().then(function(text) { + throw new Error(text || 'Import failed'); + }); + } + return response.json(); + }) + .then(function(result) { + Dashboard.hideLoadingMsg(); + loadPodcasts(); + + var message = 'Import complete!\n\n' + + 'Imported: ' + result.importedCount + '\n' + + 'Skipped (already subscribed): ' + result.skippedCount + '\n' + + 'Failed: ' + result.failedCount; + + if (result.errors && result.errors.length > 0) { + message += '\n\nFailed feeds:\n'; + result.errors.slice(0, 5).forEach(function(err) { + message += '- ' + (err.title || err.feedUrl) + ': ' + err.error + '\n'; + }); + if (result.errors.length > 5) { + message += '... and ' + (result.errors.length - 5) + ' more'; + } + } + + Dashboard.alert(message); + }) + .catch(function(err) { + Dashboard.hideLoadingMsg(); + Dashboard.alert('Import failed: ' + err.message); + }); + });
diff --git a/Jellyfin.Plugin.Jellypod/Models/OpmlOutline.cs b/Jellyfin.Plugin.Jellypod/Models/OpmlOutline.cs new file mode 100644 index 0000000..3976808 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Models/OpmlOutline.cs @@ -0,0 +1,42 @@ +namespace Jellyfin.Plugin.Jellypod.Models; + +/// +/// Represents an outline element from an OPML file. +/// +public class OpmlOutline +{ + /// + /// Gets or sets the outline title (text attribute). + /// + public string? Text { get; set; } + + /// + /// Gets or sets the outline title (title attribute). + /// + public string? Title { get; set; } + + /// + /// Gets or sets the RSS feed URL (xmlUrl attribute). + /// + public string? XmlUrl { get; set; } + + /// + /// Gets or sets the website URL (htmlUrl attribute). + /// + public string? HtmlUrl { get; set; } + + /// + /// Gets or sets the description. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the category. + /// + public string? Category { get; set; } + + /// + /// Gets the display title (prefers Title over Text). + /// + public string DisplayTitle => Title ?? Text ?? "Unknown"; +} diff --git a/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs b/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs index 05a3d73..95253d4 100644 --- a/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs +++ b/Jellyfin.Plugin.Jellypod/PluginServiceRegistrator.cs @@ -26,6 +26,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); // Register channel serviceCollection.AddSingleton(); diff --git a/Jellyfin.Plugin.Jellypod/Services/IOpmlService.cs b/Jellyfin.Plugin.Jellypod/Services/IOpmlService.cs new file mode 100644 index 0000000..c218fc3 --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/IOpmlService.cs @@ -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; + +/// +/// Service for handling OPML import and export. +/// +public interface IOpmlService +{ + /// + /// Parses OPML content and extracts podcast outlines. + /// + /// The OPML XML content. + /// List of parsed outlines. + IReadOnlyList ParseOpml(string opmlContent); + + /// + /// Parses OPML from a stream. + /// + /// The input stream containing OPML XML. + /// List of parsed outlines. + IReadOnlyList ParseOpml(Stream stream); + + /// + /// Imports podcasts from parsed OPML outlines. + /// + /// The parsed OPML outlines. + /// Cancellation token. + /// Import result with statistics. + Task ImportPodcastsAsync( + IReadOnlyList outlines, + CancellationToken cancellationToken = default); + + /// + /// Exports all subscribed podcasts to OPML format. + /// + /// OPML XML string. + Task ExportToOpmlAsync(); +} diff --git a/Jellyfin.Plugin.Jellypod/Services/OpmlService.cs b/Jellyfin.Plugin.Jellypod/Services/OpmlService.cs new file mode 100644 index 0000000..3f1d90f --- /dev/null +++ b/Jellyfin.Plugin.Jellypod/Services/OpmlService.cs @@ -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; + +/// +/// 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), "..."); + } +}