Compare commits

...

2 Commits

Author SHA1 Message Date
003a8754a6 Added OPML support
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
2025-12-16 20:31:46 +01:00
b4275837bc Updated readme
All checks were successful
🏗️ Build Plugin / build (push) Successful in 2m4s
🧪 Test Plugin / test (push) Successful in 1m1s
🚀 Release Plugin / build-and-release (push) Successful in 2m39s
fixed rending error on pod management page
2025-12-14 14:25:43 +01:00
12 changed files with 721 additions and 36 deletions

View File

@ -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");
}
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
}

View File

@ -4,42 +4,64 @@
<meta charset="utf-8">
<title>Jellypod</title>
<style>
.podcast-item {
display: flex;
align-items: center;
.podcast-table {
width: 100%;
border-collapse: collapse;
}
.podcast-table th {
text-align: left;
padding: 0.75em;
border-bottom: 2px solid rgba(255,255,255,0.2);
font-weight: 600;
}
.podcast-table td {
padding: 0.75em;
border-bottom: 1px solid rgba(255,255,255,0.1);
vertical-align: middle;
}
.podcast-item:last-child {
.podcast-table tr:last-child td {
border-bottom: none;
}
.podcast-image {
width: 50px;
height: 50px;
max-width: 50px;
max-height: 50px;
min-width: 50px;
min-height: 50px;
object-fit: cover;
border-radius: 4px;
margin-right: 1em;
background: #333;
flex-shrink: 0;
.podcast-table tr:hover {
background: rgba(255,255,255,0.05);
}
.podcast-info {
flex: 1;
.podcast-image {
width: 50px !important;
height: 50px !important;
max-width: 50px !important;
max-height: 50px !important;
min-width: 50px !important;
min-height: 50px !important;
object-fit: cover !important;
border-radius: 4px !important;
background: #333 !important;
display: block !important;
}
.col-image img {
width: 50px !important;
height: 50px !important;
max-width: 50px !important;
max-height: 50px !important;
}
.podcast-title {
font-weight: bold;
margin-bottom: 0.25em;
}
.podcast-meta {
font-size: 0.85em;
opacity: 0.7;
margin-top: 0.25em;
}
.podcast-actions {
display: flex;
gap: 0.5em;
justify-content: flex-end;
}
.col-image {
width: 50px;
}
.col-actions {
width: 100px;
text-align: right;
}
.add-podcast-form {
display: flex;
@ -157,6 +179,19 @@
<div class="verticalSection">
<h2 class="sectionTitle">Podcast Subscriptions</h2>
<!-- OPML Import/Export -->
<div class="opml-actions" style="margin-bottom: 1em; display: flex; gap: 0.5em; align-items: center;">
<button is="emby-button" type="button" id="btnExportOpml" class="raised emby-button">
<span class="material-icons" style="margin-right: 0.25em;">download</span>
<span>Export OPML</span>
</button>
<button is="emby-button" type="button" id="btnImportOpml" class="raised emby-button">
<span class="material-icons" style="margin-right: 0.25em;">upload</span>
<span>Import OPML</span>
</button>
<input type="file" id="opmlFileInput" accept=".opml,.xml" style="display: none;" />
</div>
<!-- Add Podcast Form -->
<div class="add-podcast-form">
<div class="inputContainer">
@ -224,7 +259,7 @@
return;
}
var html = podcasts.map(function(podcast) {
var rows = podcasts.map(function(podcast) {
// Handle both PascalCase (C#) and camelCase (JSON) property names
var episodeCount = (podcast.Episodes || podcast.episodes || []).length;
var lastUpdated = podcast.LastUpdated || podcast.lastUpdated;
@ -235,25 +270,36 @@
console.log('Jellypod: Rendering podcast:', podcastTitle, 'ID:', podcastId);
return '<div class="podcast-item" data-id="' + podcastId + '">' +
'<img class="podcast-image" src="' + podcastImage + '" alt="" onerror="this.style.display=\'none\'">' +
'<div class="podcast-info">' +
return '<tr data-id="' + podcastId + '">' +
'<td class="col-image">' +
'<img class="podcast-image" src="' + podcastImage + '" alt="" style="width:50px;height:50px;max-width:50px;max-height:50px;object-fit:cover;" onerror="this.style.display=\'none\'">' +
'</td>' +
'<td>' +
'<div class="podcast-title">' + escapeHtml(podcastTitle) + '</div>' +
'<div class="podcast-meta">' +
episodeCount + ' episodes | Updated: ' + lastUpdatedStr +
'<div class="podcast-meta">' + episodeCount + ' episodes | Updated: ' + lastUpdatedStr + '</div>' +
'</td>' +
'<td class="col-actions">' +
'<div class="podcast-actions">' +
'<button is="emby-button" type="button" class="emby-button" onclick="refreshPodcast(\'' + podcastId + '\')" title="Refresh Feed">' +
'<span class="material-icons">refresh</span>' +
'</button>' +
'<button is="emby-button" type="button" class="emby-button" onclick="deletePodcast(\'' + podcastId + '\')" title="Unsubscribe">' +
'<span class="material-icons">delete</span>' +
'</button>' +
'</div>' +
'</div>' +
'<div class="podcast-actions">' +
'<button is="emby-button" type="button" class="emby-button" onclick="refreshPodcast(\'' + podcastId + '\')" title="Refresh Feed">' +
'<span class="material-icons">refresh</span>' +
'</button>' +
'<button is="emby-button" type="button" class="emby-button" onclick="deletePodcast(\'' + podcastId + '\')" title="Unsubscribe">' +
'<span class="material-icons">delete</span>' +
'</button>' +
'</div>' +
'</div>';
'</td>' +
'</tr>';
}).join('');
var html = '<table class="podcast-table">' +
'<thead><tr>' +
'<th class="col-image"></th>' +
'<th>Podcast</th>' +
'<th class="col-actions">Actions</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>';
container.innerHTML = html;
console.log('Jellypod: Rendered HTML length:', html.length);
}
@ -377,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);
});
});
</script>
</div>
</body>

View File

@ -0,0 +1,42 @@
namespace Jellyfin.Plugin.Jellypod.Models;
/// <summary>
/// Represents an outline element from an OPML file.
/// </summary>
public class OpmlOutline
{
/// <summary>
/// Gets or sets the outline title (text attribute).
/// </summary>
public string? Text { get; set; }
/// <summary>
/// Gets or sets the outline title (title attribute).
/// </summary>
public string? Title { get; set; }
/// <summary>
/// Gets or sets the RSS feed URL (xmlUrl attribute).
/// </summary>
public string? XmlUrl { get; set; }
/// <summary>
/// Gets or sets the website URL (htmlUrl attribute).
/// </summary>
public string? HtmlUrl { get; set; }
/// <summary>
/// Gets or sets the description.
/// </summary>
public string? Description { get; set; }
/// <summary>
/// Gets or sets the category.
/// </summary>
public string? Category { get; set; }
/// <summary>
/// Gets the display title (prefers Title over Text).
/// </summary>
public string DisplayTitle => Title ?? Text ?? "Unknown";
}

View File

@ -26,6 +26,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton<IRssFeedService, RssFeedService>();
serviceCollection.AddSingleton<IPodcastStorageService, PodcastStorageService>();
serviceCollection.AddSingleton<IPodcastDownloadService, PodcastDownloadService>();
serviceCollection.AddSingleton<IOpmlService, OpmlService>();
// Register channel
serviceCollection.AddSingleton<IChannel, JellypodChannel>();

View File

@ -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();
}

View File

@ -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), "...");
}
}

View File

@ -8,6 +8,14 @@ A Jellyfin plugin that adds podcast support to your media server.
- Automatic episode downloads
- Integration with Jellyfin's library system
## Screenshots
### Podcast Library
![Podcast Library](images/library.png)
### Plugin Settings
![Plugin Settings](images/settings.png)
## Installation
### Manual Installation

BIN
images/library.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 KiB

BIN
images/settings.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB