using System;
using System.Collections.Generic;
using System.Linq;
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;
///
/// Controller for Jellypod podcast management.
///
[ApiController]
[Authorize]
[Route("Jellypod")]
[Produces(MediaTypeNames.Application.Json)]
public class JellypodController : ControllerBase
{
private readonly ILogger _logger;
private readonly IRssFeedService _rssFeedService;
private readonly IPodcastStorageService _storageService;
private readonly IPodcastDownloadService _downloadService;
///
/// Initializes a new instance of the class.
///
/// Logger instance.
/// RSS feed service.
/// Storage service.
/// Download service.
public JellypodController(
ILogger logger,
IRssFeedService rssFeedService,
IPodcastStorageService storageService,
IPodcastDownloadService downloadService)
{
_logger = logger;
_rssFeedService = rssFeedService;
_storageService = storageService;
_downloadService = downloadService;
}
///
/// Gets all subscribed podcasts.
///
/// List of podcasts.
[HttpGet("podcasts")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task>> GetPodcasts()
{
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
return Ok(podcasts);
}
///
/// Gets a specific podcast by ID.
///
/// Podcast ID.
/// The podcast.
[HttpGet("podcasts/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> GetPodcast([FromRoute] Guid id)
{
var podcast = await _storageService.GetPodcastAsync(id).ConfigureAwait(false);
return podcast != null ? Ok(podcast) : NotFound();
}
///
/// Adds a new podcast subscription.
///
/// Add podcast request.
/// The created podcast.
[HttpPost("podcasts")]
[ProducesResponseType(StatusCodes.Status201Created)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task> 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);
}
///
/// Deletes a podcast subscription.
///
/// Podcast ID.
/// Whether to delete downloaded files.
/// No content.
[HttpDelete("podcasts/{id}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task 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();
}
///
/// Updates a podcast's settings.
///
/// Podcast ID.
/// Update request.
/// The updated podcast.
[HttpPut("podcasts/{id}")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> 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);
}
///
/// Refreshes a podcast feed.
///
/// Podcast ID.
/// The updated podcast.
[HttpPost("podcasts/{id}/refresh")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> 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);
}
///
/// Downloads multiple episodes from a podcast.
///
/// Podcast ID.
/// Download request with count.
/// Number of episodes queued.
[HttpPost("podcasts/{id}/download")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> 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 });
}
///
/// Downloads a specific episode.
///
/// Podcast ID.
/// Episode ID.
/// Accepted status.
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/download")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task 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();
}
///
/// Deletes an episode.
///
/// Podcast ID.
/// Episode ID.
/// Whether to delete the downloaded file.
/// No content.
[HttpDelete("podcasts/{podcastId}/episodes/{episodeId}")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task 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();
}
///
/// Previews a podcast feed without subscribing.
///
/// Preview request.
/// The podcast info.
[HttpPost("preview")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task> 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");
}
///
/// Removes duplicate podcast subscriptions, keeping the first one.
///
/// Number of duplicates removed.
[HttpPost("cleanup")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task> CleanupDuplicates()
{
var podcasts = await _storageService.GetAllPodcastsAsync().ConfigureAwait(false);
var seenUrls = new HashSet(StringComparer.OrdinalIgnoreCase);
var duplicatesToRemove = new List();
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 });
}
///
/// Updates playback progress for an episode.
///
/// Podcast ID.
/// Episode ID.
/// Progress update request.
/// No content.
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/progress")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task 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();
}
///
/// Marks an episode as played or unplayed.
///
/// Podcast ID.
/// Episode ID.
/// Whether the episode is played.
/// No content.
[HttpPost("podcasts/{podcastId}/episodes/{episodeId}/played")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task 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();
}
///
/// Gets playback progress for an episode.
///
/// Podcast ID.
/// Episode ID.
/// Playback progress info.
[HttpGet("podcasts/{podcastId}/episodes/{episodeId}/progress")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public async Task> 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
});
}
}