First POC with podcasts library
🏗️ Build Plugin / call (push) Failing after 0s
📝 Create/Update Release Draft & Release Bump PR / call (push) Failing after 0s
🔬 Run CodeQL / call (push) Failing after 0s
🧪 Test Plugin / call (push) Failing after 0s

This commit is contained in:
2025-12-13 23:57:58 +01:00
parent 7a9dbdafcc
commit 4679b77d1a
31 changed files with 2998 additions and 152 deletions
@@ -0,0 +1,49 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for downloading podcast episodes.
/// </summary>
public interface IPodcastDownloadService
{
/// <summary>
/// Queues an episode for download.
/// </summary>
/// <param name="podcast">The podcast.</param>
/// <param name="episode">The episode to download.</param>
/// <returns>Task representing the queue operation.</returns>
Task QueueDownloadAsync(Podcast podcast, Episode episode);
/// <summary>
/// Downloads an episode immediately.
/// </summary>
/// <param name="podcast">The podcast.</param>
/// <param name="episode">The episode to download.</param>
/// <param name="progress">Optional progress reporter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The local file path of the downloaded episode.</returns>
Task<string> DownloadEpisodeAsync(
Podcast podcast,
Episode episode,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default);
/// <summary>
/// Deletes a downloaded episode file.
/// </summary>
/// <param name="episode">The episode whose file to delete.</param>
/// <returns>Task representing the delete operation.</returns>
Task DeleteEpisodeFileAsync(Episode episode);
/// <summary>
/// Downloads podcast artwork.
/// </summary>
/// <param name="podcast">The podcast.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Task representing the download operation.</returns>
Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,60 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for storing and retrieving podcast data.
/// </summary>
public interface IPodcastStorageService
{
/// <summary>
/// Gets all subscribed podcasts.
/// </summary>
/// <returns>List of all podcasts.</returns>
Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync();
/// <summary>
/// Gets a podcast by its ID.
/// </summary>
/// <param name="id">The podcast ID.</param>
/// <returns>The podcast, or null if not found.</returns>
Task<Podcast?> GetPodcastAsync(Guid id);
/// <summary>
/// Adds a new podcast subscription.
/// </summary>
/// <param name="podcast">The podcast to add.</param>
/// <returns>Task representing the operation.</returns>
Task AddPodcastAsync(Podcast podcast);
/// <summary>
/// Updates an existing podcast.
/// </summary>
/// <param name="podcast">The podcast to update.</param>
/// <returns>Task representing the operation.</returns>
Task UpdatePodcastAsync(Podcast podcast);
/// <summary>
/// Deletes a podcast subscription.
/// </summary>
/// <param name="id">The podcast ID to delete.</param>
/// <returns>Task representing the operation.</returns>
Task DeletePodcastAsync(Guid id);
/// <summary>
/// Gets the local file path for an episode.
/// </summary>
/// <param name="podcast">The parent podcast.</param>
/// <param name="episode">The episode.</param>
/// <returns>The file path where the episode should be stored.</returns>
string GetEpisodeFilePath(Podcast podcast, Episode episode);
/// <summary>
/// Gets the storage path for podcasts.
/// </summary>
/// <returns>The base path for podcast storage.</returns>
string GetStoragePath();
}
@@ -0,0 +1,19 @@
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for fetching and parsing podcast RSS feeds.
/// </summary>
public interface IRssFeedService
{
/// <summary>
/// Fetches and parses a podcast from an RSS feed URL.
/// </summary>
/// <param name="feedUrl">The RSS feed URL.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The parsed podcast with episodes, or null if parsing failed.</returns>
Task<Podcast?> FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default);
}
@@ -0,0 +1,248 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for downloading podcast episodes.
/// </summary>
public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposable
{
private readonly ILogger<PodcastDownloadService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IPodcastStorageService _storageService;
private readonly ConcurrentQueue<(Podcast Podcast, Episode Episode)> _downloadQueue = new();
private readonly SemaphoreSlim _downloadSemaphore;
private int _isProcessing;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastDownloadService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
/// <param name="storageService">Storage service.</param>
public PodcastDownloadService(
ILogger<PodcastDownloadService> logger,
IHttpClientFactory httpClientFactory,
IPodcastStorageService storageService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_storageService = storageService;
var maxConcurrent = Plugin.Instance?.Configuration?.MaxConcurrentDownloads ?? 2;
_downloadSemaphore = new SemaphoreSlim(maxConcurrent, maxConcurrent);
}
/// <inheritdoc />
public Task QueueDownloadAsync(Podcast podcast, Episode episode)
{
_downloadQueue.Enqueue((podcast, episode));
_logger.LogDebug("Queued download: {PodcastTitle} - {EpisodeTitle}", podcast.Title, episode.Title);
// Start processing if not already running
if (Interlocked.CompareExchange(ref _isProcessing, 1, 0) == 0)
{
_ = ProcessQueueAsync();
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task<string> DownloadEpisodeAsync(
Podcast podcast,
Episode episode,
IProgress<double>? progress = null,
CancellationToken cancellationToken = default)
{
var filePath = _storageService.GetEpisodeFilePath(podcast, episode);
var directory = Path.GetDirectoryName(filePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
_logger.LogInformation("Downloading episode: {Title} to {Path}", episode.Title, filePath);
try
{
episode.Status = EpisodeStatus.Downloading;
var httpClient = _httpClientFactory.CreateClient("Jellypod");
using var response = await httpClient.GetAsync(
episode.AudioUrl,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var totalBytes = response.Content.Headers.ContentLength ?? -1;
var contentStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
long totalRead;
try
{
var fileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, 81920, true);
try
{
var buffer = new byte[81920];
totalRead = 0L;
int bytesRead;
while ((bytesRead = await contentStream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false)) > 0)
{
await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken).ConfigureAwait(false);
totalRead += bytesRead;
if (totalBytes > 0)
{
progress?.Report((double)totalRead / totalBytes * 100);
}
}
}
finally
{
await fileStream.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
await contentStream.DisposeAsync().ConfigureAwait(false);
}
episode.LocalFilePath = filePath;
episode.Status = EpisodeStatus.Downloaded;
episode.DownloadedDate = DateTime.UtcNow;
episode.FileSizeBytes = totalRead;
_logger.LogInformation("Downloaded episode: {Title} ({Size} bytes)", episode.Title, totalRead);
// Update the podcast in storage
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
return filePath;
}
catch (Exception ex)
{
episode.Status = EpisodeStatus.Error;
_logger.LogError(ex, "Failed to download episode: {Title}", episode.Title);
throw;
}
}
/// <inheritdoc />
public Task DeleteEpisodeFileAsync(Episode episode)
{
if (string.IsNullOrEmpty(episode.LocalFilePath))
{
return Task.CompletedTask;
}
try
{
if (File.Exists(episode.LocalFilePath))
{
File.Delete(episode.LocalFilePath);
_logger.LogInformation("Deleted episode file: {Path}", episode.LocalFilePath);
}
episode.LocalFilePath = null;
episode.Status = EpisodeStatus.Available;
episode.DownloadedDate = null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to delete episode file: {Path}", episode.LocalFilePath);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public async Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(podcast.ImageUrl))
{
return;
}
var config = Plugin.Instance?.Configuration;
if (config?.CreatePodcastFolders != true)
{
return;
}
var basePath = _storageService.GetStoragePath();
var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title));
var artworkPath = Path.Combine(podcastFolder, "folder.jpg");
if (File.Exists(artworkPath))
{
return;
}
try
{
Directory.CreateDirectory(podcastFolder);
var httpClient = _httpClientFactory.CreateClient("Jellypod");
var imageBytes = await httpClient.GetByteArrayAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false);
await File.WriteAllBytesAsync(artworkPath, imageBytes, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Downloaded artwork for podcast: {Title}", podcast.Title);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download artwork for podcast: {Title}", podcast.Title);
}
}
private async Task ProcessQueueAsync()
{
try
{
while (_downloadQueue.TryDequeue(out var item))
{
await _downloadSemaphore.WaitAsync().ConfigureAwait(false);
try
{
await DownloadEpisodeAsync(item.Podcast, item.Episode).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to process queued download: {Title}", item.Episode.Title);
}
finally
{
_downloadSemaphore.Release();
}
}
}
finally
{
Interlocked.Exchange(ref _isProcessing, 0);
}
}
private static string SanitizeFileName(string name)
{
var invalidChars = Path.GetInvalidFileNameChars();
var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray());
return result.Length > 100 ? result.Substring(0, 100).Trim() : result.Trim();
}
/// <inheritdoc />
public void Dispose()
{
_downloadSemaphore.Dispose();
}
}
@@ -0,0 +1,269 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Jellypod.Models;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for storing and retrieving podcast data.
/// </summary>
public sealed class PodcastStorageService : IPodcastStorageService, IDisposable
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
PropertyNameCaseInsensitive = true
};
private readonly ILogger<PodcastStorageService> _logger;
private readonly IApplicationPaths _applicationPaths;
private readonly SemaphoreSlim _dbLock = new(1, 1);
private PodcastDatabase? _cache;
/// <summary>
/// Initializes a new instance of the <see cref="PodcastStorageService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="applicationPaths">Application paths.</param>
public PodcastStorageService(ILogger<PodcastStorageService> logger, IApplicationPaths applicationPaths)
{
_logger = logger;
_applicationPaths = applicationPaths;
_logger.LogInformation("Jellypod database path: {Path}", DatabasePath);
_logger.LogInformation("PluginConfigurationsPath: {Path}", applicationPaths.PluginConfigurationsPath);
}
private string DatabasePath => Path.Combine(
_applicationPaths.PluginConfigurationsPath,
"Jellypod",
"podcasts.json");
/// <inheritdoc />
public async Task<IReadOnlyList<Podcast>> GetAllPodcastsAsync()
{
var db = await LoadDatabaseAsync().ConfigureAwait(false);
return db.Podcasts.ToList();
}
/// <inheritdoc />
public async Task<Podcast?> GetPodcastAsync(Guid id)
{
var db = await LoadDatabaseAsync().ConfigureAwait(false);
return db.Podcasts.FirstOrDefault(p => p.Id == id);
}
/// <inheritdoc />
public async Task AddPodcastAsync(Podcast podcast)
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
db.Podcasts.Add(podcast);
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
_logger.LogInformation("Added podcast: {Title}", podcast.Title);
}
finally
{
_dbLock.Release();
}
}
/// <inheritdoc />
public async Task UpdatePodcastAsync(Podcast podcast)
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
var existing = db.Podcasts.FirstOrDefault(p => p.Id == podcast.Id);
if (existing != null)
{
var index = db.Podcasts.IndexOf(existing);
db.Podcasts[index] = podcast;
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
_logger.LogDebug("Updated podcast: {Title}", podcast.Title);
}
}
finally
{
_dbLock.Release();
}
}
/// <inheritdoc />
public async Task DeletePodcastAsync(Guid id)
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
var db = await LoadDatabaseInternalAsync().ConfigureAwait(false);
var podcast = db.Podcasts.FirstOrDefault(p => p.Id == id);
if (podcast != null)
{
db.Podcasts.Remove(podcast);
await SaveDatabaseInternalAsync(db).ConfigureAwait(false);
_logger.LogInformation("Deleted podcast: {Title}", podcast.Title);
}
}
finally
{
_dbLock.Release();
}
}
/// <inheritdoc />
public string GetEpisodeFilePath(Podcast podcast, Episode episode)
{
var basePath = GetStoragePath();
var config = Plugin.Instance?.Configuration;
var safePodcastTitle = SanitizeFileName(podcast.Title);
var safeEpisodeTitle = SanitizeFileName(episode.Title);
var extension = GetAudioExtension(episode.AudioUrl);
// Format: YYYY-MM-DD - Episode Title.mp3
var datePrefix = episode.PublishedDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var fileName = $"{datePrefix} - {safeEpisodeTitle}{extension}";
if (config?.CreatePodcastFolders == true)
{
return Path.Combine(basePath, safePodcastTitle, fileName);
}
return Path.Combine(basePath, $"{safePodcastTitle} - {fileName}");
}
/// <inheritdoc />
public string GetStoragePath()
{
var config = Plugin.Instance?.Configuration;
if (!string.IsNullOrEmpty(config?.PodcastStoragePath))
{
return config.PodcastStoragePath;
}
return Path.Combine(_applicationPaths.DataPath, "Podcasts");
}
private async Task<PodcastDatabase> LoadDatabaseAsync()
{
await _dbLock.WaitAsync().ConfigureAwait(false);
try
{
return await LoadDatabaseInternalAsync().ConfigureAwait(false);
}
finally
{
_dbLock.Release();
}
}
private async Task<PodcastDatabase> LoadDatabaseInternalAsync()
{
if (_cache != null)
{
return _cache;
}
if (!File.Exists(DatabasePath))
{
_logger.LogWarning("Database file does not exist at {Path}", DatabasePath);
_cache = new PodcastDatabase();
return _cache;
}
try
{
_logger.LogInformation("Loading database from {Path}", DatabasePath);
var json = await File.ReadAllTextAsync(DatabasePath).ConfigureAwait(false);
_logger.LogInformation("Read {Length} characters from database file", json.Length);
_cache = JsonSerializer.Deserialize<PodcastDatabase>(json, JsonOptions) ?? new PodcastDatabase();
_logger.LogInformation("Loaded {Count} podcasts from database", _cache.Podcasts.Count);
return _cache;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to load podcast database, starting fresh");
_cache = new PodcastDatabase();
return _cache;
}
}
private async Task SaveDatabaseInternalAsync(PodcastDatabase db)
{
try
{
var directory = Path.GetDirectoryName(DatabasePath);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
db.LastSaved = DateTime.UtcNow;
var json = JsonSerializer.Serialize(db, JsonOptions);
await File.WriteAllTextAsync(DatabasePath, json).ConfigureAwait(false);
_cache = db;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save podcast database");
throw;
}
}
private static string SanitizeFileName(string name)
{
var invalidChars = Path.GetInvalidFileNameChars();
var result = new string(name.Where(c => !invalidChars.Contains(c)).ToArray());
// Limit length
if (result.Length > 100)
{
result = result.Substring(0, 100);
}
return result.Trim();
}
private static string GetAudioExtension(string url)
{
try
{
var uri = new Uri(url);
var path = uri.AbsolutePath;
var extension = Path.GetExtension(path);
if (!string.IsNullOrEmpty(extension) &&
(extension.Equals(".mp3", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".m4a", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".ogg", StringComparison.OrdinalIgnoreCase) ||
extension.Equals(".opus", StringComparison.OrdinalIgnoreCase)))
{
return extension.ToLowerInvariant();
}
}
catch
{
// Ignore URL parsing errors
}
return ".mp3";
}
/// <inheritdoc />
public void Dispose()
{
_dbLock.Dispose();
}
}
@@ -0,0 +1,220 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.ServiceModel.Syndication;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using Jellyfin.Plugin.Jellypod.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Jellypod.Services;
/// <summary>
/// Service for fetching and parsing podcast RSS feeds.
/// </summary>
public class RssFeedService : IRssFeedService
{
private static readonly XNamespace ItunesNs = "http://www.itunes.com/dtds/podcast-1.0.dtd";
private static readonly XNamespace ContentNs = "http://purl.org/rss/1.0/modules/content/";
private readonly ILogger<RssFeedService> _logger;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="RssFeedService"/> class.
/// </summary>
/// <param name="logger">Logger instance.</param>
/// <param name="httpClientFactory">HTTP client factory.</param>
public RssFeedService(ILogger<RssFeedService> logger, IHttpClientFactory httpClientFactory)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
}
/// <inheritdoc />
public async Task<Podcast?> FetchPodcastAsync(string feedUrl, CancellationToken cancellationToken = default)
{
try
{
var httpClient = _httpClientFactory.CreateClient("Jellypod");
using var response = await httpClient.GetAsync(feedUrl, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
using var reader = XmlReader.Create(stream);
var feed = SyndicationFeed.Load(reader);
var podcast = new Podcast
{
FeedUrl = feedUrl,
Title = feed.Title?.Text ?? "Unknown Podcast",
Description = StripHtml(feed.Description?.Text ?? string.Empty),
ImageUrl = GetItunesImage(feed) ?? feed.ImageUrl?.ToString(),
Author = GetItunesAuthor(feed),
Language = feed.Language,
LastUpdated = DateTime.UtcNow
};
// Parse episodes
foreach (var item in feed.Items)
{
var episode = ParseEpisode(item, podcast.Id);
if (episode != null)
{
podcast.Episodes.Add(episode);
}
}
_logger.LogInformation("Fetched podcast '{Title}' with {Count} episodes", podcast.Title, podcast.Episodes.Count);
return podcast;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch podcast from {FeedUrl}", feedUrl);
return null;
}
}
private Episode? ParseEpisode(SyndicationItem item, Guid podcastId)
{
// Find audio enclosure
var enclosure = item.Links.FirstOrDefault(l =>
string.Equals(l.RelationshipType, "enclosure", StringComparison.OrdinalIgnoreCase) &&
(l.MediaType?.StartsWith("audio/", StringComparison.OrdinalIgnoreCase) == true ||
l.Uri?.ToString().EndsWith(".mp3", StringComparison.OrdinalIgnoreCase) == true ||
l.Uri?.ToString().EndsWith(".m4a", StringComparison.OrdinalIgnoreCase) == true));
if (enclosure == null)
{
return null;
}
return new Episode
{
PodcastId = podcastId,
Title = item.Title?.Text ?? "Untitled Episode",
Description = StripHtml(item.Summary?.Text ?? GetContentEncoded(item) ?? string.Empty),
AudioUrl = enclosure.Uri.ToString(),
FileSizeBytes = enclosure.Length > 0 ? enclosure.Length : null,
PublishedDate = item.PublishDate.UtcDateTime,
EpisodeGuid = item.Id ?? enclosure.Uri.ToString(),
Duration = GetItunesDuration(item),
SeasonNumber = GetItunesSeason(item),
EpisodeNumber = GetItunesEpisode(item),
ImageUrl = GetItunesEpisodeImage(item)
};
}
private static string? GetItunesImage(SyndicationFeed feed)
{
var imageElement = feed.ElementExtensions
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
if (imageElement != null)
{
var element = imageElement.GetObject<XElement>();
return element.Attribute("href")?.Value;
}
return null;
}
private static string? GetItunesAuthor(SyndicationFeed feed)
{
var authorElement = feed.ElementExtensions
.FirstOrDefault(e => e.OuterName == "author" && e.OuterNamespace == ItunesNs.NamespaceName);
return authorElement?.GetObject<XElement>()?.Value;
}
private static TimeSpan? GetItunesDuration(SyndicationItem item)
{
var durationElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "duration" && e.OuterNamespace == ItunesNs.NamespaceName);
if (durationElement == null)
{
return null;
}
var durationStr = durationElement.GetObject<XElement>()?.Value;
if (string.IsNullOrEmpty(durationStr))
{
return null;
}
// Duration can be in formats: HH:MM:SS, MM:SS, or just seconds
var parts = durationStr.Split(':');
return parts.Length switch
{
3 when int.TryParse(parts[0], out var h) && int.TryParse(parts[1], out var m) && int.TryParse(parts[2], out var s)
=> new TimeSpan(h, m, s),
2 when int.TryParse(parts[0], out var m) && int.TryParse(parts[1], out var s)
=> new TimeSpan(0, m, s),
1 when int.TryParse(parts[0], out var s)
=> TimeSpan.FromSeconds(s),
_ => null
};
}
private static int? GetItunesSeason(SyndicationItem item)
{
var seasonElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "season" && e.OuterNamespace == ItunesNs.NamespaceName);
var value = seasonElement?.GetObject<XElement>()?.Value;
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var season) ? season : null;
}
private static int? GetItunesEpisode(SyndicationItem item)
{
var episodeElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "episode" && e.OuterNamespace == ItunesNs.NamespaceName);
var value = episodeElement?.GetObject<XElement>()?.Value;
return int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var episode) ? episode : null;
}
private static string? GetItunesEpisodeImage(SyndicationItem item)
{
var imageElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "image" && e.OuterNamespace == ItunesNs.NamespaceName);
if (imageElement != null)
{
var element = imageElement.GetObject<XElement>();
return element.Attribute("href")?.Value;
}
return null;
}
private static string? GetContentEncoded(SyndicationItem item)
{
var contentElement = item.ElementExtensions
.FirstOrDefault(e => e.OuterName == "encoded" && e.OuterNamespace == ContentNs.NamespaceName);
return contentElement?.GetObject<XElement>()?.Value;
}
private static string StripHtml(string html)
{
if (string.IsNullOrEmpty(html))
{
return string.Empty;
}
// Simple HTML stripping - remove tags
var result = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]*>", string.Empty);
// Decode common HTML entities
result = result.Replace("&nbsp;", " ", StringComparison.Ordinal)
.Replace("&amp;", "&", StringComparison.Ordinal)
.Replace("&lt;", "<", StringComparison.Ordinal)
.Replace("&gt;", ">", StringComparison.Ordinal)
.Replace("&quot;", "\"", StringComparison.Ordinal);
return result.Trim();
}
}