Added OPML support
🚀 Release Plugin / build-and-release (push) Successful in 2m3s
🏗️ Build Plugin / build (push) Successful in 2m0s
🧪 Test Plugin / test (push) Successful in 59s

This commit is contained in:
2025-12-16 20:31:46 +01:00
parent b4275837bc
commit 003a8754a6
9 changed files with 645 additions and 1 deletions
@@ -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;
/// <summary>
/// Initializes a new instance of the <see cref="JellypodController"/> class.
@@ -34,16 +37,22 @@ public class JellypodController : ControllerBase
/// <param name="rssFeedService">RSS feed service.</param>
/// <param name="storageService">Storage service.</param>
/// <param name="downloadService">Download service.</param>
/// <param name="opmlService">OPML service.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
public JellypodController(
ILogger<JellypodController> 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;
}
/// <summary>
@@ -474,4 +483,102 @@ public class JellypodController : ControllerBase
PlayCount = episode.PlayCount
});
}
/// <summary>
/// Exports all podcast subscriptions as OPML.
/// </summary>
/// <returns>OPML XML file.</returns>
[HttpGet("opml/export")]
[Produces("application/xml")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<IActionResult> 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);
}
/// <summary>
/// Imports podcasts from an uploaded OPML file.
/// </summary>
/// <param name="file">The OPML file to import.</param>
/// <returns>Import results.</returns>
[HttpPost("opml/import")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OpmlImportResult>> 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");
}
}
/// <summary>
/// Imports podcasts from an OPML URL.
/// </summary>
/// <param name="request">Request containing the OPML URL.</param>
/// <returns>Import results.</returns>
[HttpPost("opml/import-url")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<OpmlImportResult>> 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");
}
}
}
@@ -0,0 +1,22 @@
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Details about a failed OPML feed import.
/// </summary>
public class OpmlImportError
{
/// <summary>
/// Gets or sets the feed URL that failed.
/// </summary>
public string FeedUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the feed title from OPML (if available).
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets or sets the error message.
/// </summary>
public string Error { get; set; } = string.Empty;
}
@@ -0,0 +1,12 @@
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Request to import podcasts from an OPML URL.
/// </summary>
public class OpmlImportRequest
{
/// <summary>
/// Gets or sets the URL to fetch OPML from.
/// </summary>
public string Url { get; set; } = string.Empty;
}
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
namespace Jellyfin.Plugin.Jellypod.Api.Models;
/// <summary>
/// Result of an OPML import operation.
/// </summary>
public class OpmlImportResult
{
/// <summary>
/// Gets or sets the total number of feeds found in the OPML.
/// </summary>
public int TotalFeeds { get; set; }
/// <summary>
/// Gets or sets the number of feeds successfully imported.
/// </summary>
public int ImportedCount { get; set; }
/// <summary>
/// Gets or sets the number of feeds skipped (already subscribed).
/// </summary>
public int SkippedCount { get; set; }
/// <summary>
/// Gets or sets the number of feeds that failed to import.
/// </summary>
public int FailedCount { get; set; }
/// <summary>
/// Gets the list of imported podcast titles.
/// </summary>
public Collection<string> ImportedPodcasts { get; } = new();
/// <summary>
/// Gets the list of skipped feed URLs (already subscribed).
/// </summary>
public Collection<string> SkippedFeeds { get; } = new();
/// <summary>
/// Gets the list of failed imports with error details.
/// </summary>
public Collection<OpmlImportError> Errors { get; } = new();
}