First POC with podcasts library
This commit is contained in:
@@ -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(" ", " ", StringComparison.Ordinal)
|
||||
.Replace("&", "&", StringComparison.Ordinal)
|
||||
.Replace("<", "<", StringComparison.Ordinal)
|
||||
.Replace(">", ">", StringComparison.Ordinal)
|
||||
.Replace(""", "\"", StringComparison.Ordinal);
|
||||
return result.Trim();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user