585 lines
21 KiB
C#
585 lines
21 KiB
C#
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;
|
|
using Jellyfin.Plugin.Jellypod.Models;
|
|
using Jellyfin.Plugin.Jellypod.Services;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Http;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.Extensions.Logging;
|
|
|
|
namespace Jellyfin.Plugin.Jellypod.Api;
|
|
|
|
/// <summary>
|
|
/// Controller for Jellypod podcast management.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Authorize]
|
|
[Route("Jellypod")]
|
|
[Produces(MediaTypeNames.Application.Json)]
|
|
public class JellypodController : ControllerBase
|
|
{
|
|
private readonly ILogger<JellypodController> _logger;
|
|
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.
|
|
/// </summary>
|
|
/// <param name="logger">Logger instance.</param>
|
|
/// <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,
|
|
IOpmlService opmlService,
|
|
IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_rssFeedService = rssFeedService;
|
|
_storageService = storageService;
|
|
_downloadService = downloadService;
|
|
_opmlService = opmlService;
|
|
_httpClientFactory = httpClientFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets all subscribed podcasts.
|
|
/// </summary>
|
|
/// <returns>List of podcasts.</returns>
|
|
[HttpGet("podcasts")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<IEnumerable<Podcast>>> GetPodcasts()
|
|
{
|
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
|
return Ok(podcasts);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets a specific podcast by ID.
|
|
/// </summary>
|
|
/// <param name="id">Podcast ID.</param>
|
|
/// <returns>The podcast.</returns>
|
|
[HttpGet("podcasts/{id}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult<Podcast>> GetPodcast([FromRoute] Guid id)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
|
return podcast != null ? Ok(podcast) : NotFound();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Adds a new podcast subscription.
|
|
/// </summary>
|
|
/// <param name="request">Add podcast request.</param>
|
|
/// <returns>The created podcast.</returns>
|
|
[HttpPost("podcasts")]
|
|
[ProducesResponseType(StatusCodes.Status201Created)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
|
public async Task<ActionResult<Podcast>> AddPodcast([FromBody] AddPodcastRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.FeedUrl))
|
|
{
|
|
return BadRequest("Feed URL is required");
|
|
}
|
|
|
|
// Check if already subscribed
|
|
var existingPodcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
|
var existing = existingPodcasts.FirstOrDefault(p =>
|
|
string.Equals(p.FeedUrl, request.FeedUrl, StringComparison.OrdinalIgnoreCase));
|
|
if (existing != null)
|
|
{
|
|
return Conflict($"Already subscribed to this podcast: {existing.Title}");
|
|
}
|
|
|
|
// Fetch and validate the feed
|
|
var podcast = await _rssFeedService.FetchPodcastAsync(request.FeedUrl).ConfigureAwait(false);
|
|
if (podcast == null)
|
|
{
|
|
return BadRequest("Invalid or inaccessible RSS feed");
|
|
}
|
|
|
|
podcast.AutoDownloadEnabled = request.AutoDownload ?? true;
|
|
await _storageService.AddPodcastAsync(podcast).ConfigureAwait(false);
|
|
|
|
// Download podcast artwork
|
|
await _downloadService.DownloadPodcastArtworkAsync(podcast).ConfigureAwait(false);
|
|
|
|
_logger.LogInformation("Added podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl);
|
|
|
|
return CreatedAtAction(nameof(GetPodcast), new { id = podcast.Id }, podcast);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes a podcast subscription.
|
|
/// </summary>
|
|
/// <param name="id">Podcast ID.</param>
|
|
/// <param name="deleteFiles">Whether to delete downloaded files.</param>
|
|
/// <returns>No content.</returns>
|
|
[HttpDelete("podcasts/{id}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult> DeletePodcast([FromRoute] Guid id, [FromQuery] bool deleteFiles = false)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
|
if (podcast == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (deleteFiles)
|
|
{
|
|
foreach (var episode in podcast.Episodes.Where(e => e.Status == EpisodeStatus.Downloaded))
|
|
{
|
|
await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
await _storageService.DeletePodcastAsync(id).ConfigureAwait(false);
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates a podcast's settings.
|
|
/// </summary>
|
|
/// <param name="id">Podcast ID.</param>
|
|
/// <param name="request">Update request.</param>
|
|
/// <returns>The updated podcast.</returns>
|
|
[HttpPut("podcasts/{id}")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult<Podcast>> UpdatePodcast([FromRoute] Guid id, [FromBody] UpdatePodcastRequest request)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
|
if (podcast == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (request.AutoDownload.HasValue)
|
|
{
|
|
podcast.AutoDownloadEnabled = request.AutoDownload.Value;
|
|
}
|
|
|
|
if (request.MaxEpisodesToKeep.HasValue)
|
|
{
|
|
podcast.MaxEpisodesToKeep = request.MaxEpisodesToKeep.Value;
|
|
}
|
|
|
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
|
return Ok(podcast);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refreshes a podcast feed.
|
|
/// </summary>
|
|
/// <param name="id">Podcast ID.</param>
|
|
/// <returns>The updated podcast.</returns>
|
|
[HttpPost("podcasts/{id}/refresh")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult<Podcast>> RefreshPodcast([FromRoute] Guid id)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
|
if (podcast == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var updatedPodcast = await _rssFeedService.FetchPodcastAsync(podcast.FeedUrl).ConfigureAwait(false);
|
|
if (updatedPodcast == null)
|
|
{
|
|
return BadRequest("Failed to fetch podcast feed");
|
|
}
|
|
|
|
// Find new episodes
|
|
var existingGuids = podcast.Episodes
|
|
.Select(e => e.EpisodeGuid)
|
|
.Where(g => !string.IsNullOrEmpty(g))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
var newEpisodes = updatedPodcast.Episodes
|
|
.Where(e => !string.IsNullOrEmpty(e.EpisodeGuid) && !existingGuids.Contains(e.EpisodeGuid))
|
|
.ToList();
|
|
|
|
foreach (var episode in newEpisodes)
|
|
{
|
|
episode.PodcastId = podcast.Id;
|
|
podcast.Episodes.Insert(0, episode);
|
|
}
|
|
|
|
podcast.LastUpdated = DateTime.UtcNow;
|
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
|
|
|
return Ok(podcast);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads multiple episodes from a podcast.
|
|
/// </summary>
|
|
/// <param name="id">Podcast ID.</param>
|
|
/// <param name="request">Download request with count.</param>
|
|
/// <returns>Number of episodes queued.</returns>
|
|
[HttpPost("podcasts/{id}/download")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult<DownloadResult>> DownloadEpisodes([FromRoute] Guid id, [FromBody] DownloadEpisodesRequest request)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
|
|
if (podcast == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
var count = request.Count > 0 ? request.Count : 5;
|
|
var episodesToDownload = podcast.Episodes
|
|
.Where(e => e.Status == EpisodeStatus.Available)
|
|
.OrderByDescending(e => e.PublishedDate)
|
|
.Take(count)
|
|
.ToList();
|
|
|
|
foreach (var episode in episodesToDownload)
|
|
{
|
|
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
|
|
}
|
|
|
|
_logger.LogInformation("Queued {Count} episodes for download from {Podcast}", episodesToDownload.Count, podcast.Title);
|
|
|
|
return Ok(new DownloadResult { QueuedCount = episodesToDownload.Count });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Downloads a specific episode.
|
|
/// </summary>
|
|
/// <param name="podcastId">Podcast ID.</param>
|
|
/// <param name="episodeId">Episode ID.</param>
|
|
/// <returns>Accepted status.</returns>
|
|
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/download")]
|
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult> DownloadEpisode([FromRoute] Guid podcastId, [FromRoute] Guid episodeId)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
|
|
|
if (podcast == null || episode == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
await _downloadService.QueueDownloadAsync(podcast, episode).ConfigureAwait(false);
|
|
return Accepted();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Deletes an episode.
|
|
/// </summary>
|
|
/// <param name="podcastId">Podcast ID.</param>
|
|
/// <param name="episodeId">Episode ID.</param>
|
|
/// <param name="deleteFile">Whether to delete the downloaded file.</param>
|
|
/// <returns>No content.</returns>
|
|
[HttpDelete("podcasts/{podcastId}/episodes/{episodeId}")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult> DeleteEpisode(
|
|
[FromRoute] Guid podcastId,
|
|
[FromRoute] Guid episodeId,
|
|
[FromQuery] bool deleteFile = true)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
|
|
|
if (podcast == null || episode == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
if (deleteFile && episode.Status == EpisodeStatus.Downloaded)
|
|
{
|
|
await _downloadService.DeleteEpisodeFileAsync(episode).ConfigureAwait(false);
|
|
}
|
|
|
|
podcast.Episodes.Remove(episode);
|
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Previews a podcast feed without subscribing.
|
|
/// </summary>
|
|
/// <param name="request">Preview request.</param>
|
|
/// <returns>The podcast info.</returns>
|
|
[HttpPost("preview")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
public async Task<ActionResult<Podcast>> PreviewFeed([FromBody] PreviewFeedRequest request)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(request.FeedUrl))
|
|
{
|
|
return BadRequest("Feed URL is required");
|
|
}
|
|
|
|
var podcast = await _rssFeedService.FetchPodcastAsync(request.FeedUrl).ConfigureAwait(false);
|
|
return podcast != null ? Ok(podcast) : BadRequest("Invalid or inaccessible RSS feed");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Removes duplicate podcast subscriptions, keeping the first one.
|
|
/// </summary>
|
|
/// <returns>Number of duplicates removed.</returns>
|
|
[HttpPost("cleanup")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public async Task<ActionResult<object>> CleanupDuplicates()
|
|
{
|
|
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
|
|
var seenUrls = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
var duplicatesToRemove = new List<Guid>();
|
|
|
|
foreach (var podcast in podcasts)
|
|
{
|
|
if (seenUrls.Contains(podcast.FeedUrl))
|
|
{
|
|
duplicatesToRemove.Add(podcast.Id);
|
|
}
|
|
else
|
|
{
|
|
seenUrls.Add(podcast.FeedUrl);
|
|
}
|
|
}
|
|
|
|
foreach (var id in duplicatesToRemove)
|
|
{
|
|
await _storageService.DeletePodcastAsync(id).ConfigureAwait(false);
|
|
}
|
|
|
|
_logger.LogInformation("Removed {Count} duplicate podcast subscriptions", duplicatesToRemove.Count);
|
|
|
|
return Ok(new { RemovedCount = duplicatesToRemove.Count });
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates playback progress for an episode.
|
|
/// </summary>
|
|
/// <param name="podcastId">Podcast ID.</param>
|
|
/// <param name="episodeId">Episode ID.</param>
|
|
/// <param name="request">Progress update request.</param>
|
|
/// <returns>No content.</returns>
|
|
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/progress")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult> UpdatePlaybackProgress(
|
|
[FromRoute] Guid podcastId,
|
|
[FromRoute] Guid episodeId,
|
|
[FromBody] PlaybackProgressRequest request)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
|
|
|
if (podcast == null || episode == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
episode.PlaybackPositionTicks = request.PositionTicks;
|
|
episode.LastPlayedDate = DateTime.UtcNow;
|
|
|
|
// Mark as played if we've reached near the end (within last 5%)
|
|
if (episode.Duration.HasValue && request.PositionTicks > 0)
|
|
{
|
|
var durationTicks = episode.Duration.Value.Ticks;
|
|
var percentComplete = (double)request.PositionTicks / durationTicks;
|
|
if (percentComplete >= 0.95)
|
|
{
|
|
episode.IsPlayed = true;
|
|
episode.PlayCount++;
|
|
_logger.LogInformation("Episode marked as played: {Title} (played {Count} times)", episode.Title, episode.PlayCount);
|
|
}
|
|
}
|
|
|
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Marks an episode as played or unplayed.
|
|
/// </summary>
|
|
/// <param name="podcastId">Podcast ID.</param>
|
|
/// <param name="episodeId">Episode ID.</param>
|
|
/// <param name="played">Whether the episode is played.</param>
|
|
/// <returns>No content.</returns>
|
|
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/played")]
|
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult> SetPlayedStatus(
|
|
[FromRoute] Guid podcastId,
|
|
[FromRoute] Guid episodeId,
|
|
[FromQuery] bool played = true)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
|
|
|
if (podcast == null || episode == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
episode.IsPlayed = played;
|
|
if (played)
|
|
{
|
|
episode.LastPlayedDate = DateTime.UtcNow;
|
|
episode.PlayCount++;
|
|
}
|
|
else
|
|
{
|
|
episode.PlaybackPositionTicks = 0;
|
|
}
|
|
|
|
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
|
_logger.LogInformation("Episode {Title} marked as {Status}", episode.Title, played ? "played" : "unplayed");
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets playback progress for an episode.
|
|
/// </summary>
|
|
/// <param name="podcastId">Podcast ID.</param>
|
|
/// <param name="episodeId">Episode ID.</param>
|
|
/// <returns>Playback progress info.</returns>
|
|
[HttpGet("podcasts/{podcastId}/episodes/{episodeId}/progress")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
|
public async Task<ActionResult<PlaybackProgressResponse>> GetPlaybackProgress(
|
|
[FromRoute] Guid podcastId,
|
|
[FromRoute] Guid episodeId)
|
|
{
|
|
var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false);
|
|
var episode = podcast?.Episodes.FirstOrDefault(e => e.Id == episodeId);
|
|
|
|
if (podcast == null || episode == null)
|
|
{
|
|
return NotFound();
|
|
}
|
|
|
|
return Ok(new PlaybackProgressResponse
|
|
{
|
|
PositionTicks = episode.PlaybackPositionTicks,
|
|
IsPlayed = episode.IsPlayed,
|
|
LastPlayedDate = episode.LastPlayedDate,
|
|
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");
|
|
}
|
|
}
|
|
}
|