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