using System; using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Jellypod.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Jellypod.Api; /// /// Controller for serving podcast and episode images. /// [ApiController] [Route("Jellypod/Image")] public class ImageController : ControllerBase { private readonly ILogger _logger; private readonly IPodcastStorageService _storageService; private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. /// /// Logger instance. /// Storage service instance. /// HTTP client factory. public ImageController( ILogger logger, IPodcastStorageService storageService, IHttpClientFactory httpClientFactory) { _logger = logger; _storageService = storageService; _httpClientFactory = httpClientFactory; } /// /// Gets the image for a podcast. /// /// The podcast ID. /// Cancellation token. /// The image file. [HttpGet("podcast/{podcastId}")] [AllowAnonymous] [SuppressMessage("Microsoft.Security", "CA3003:ReviewCodeForFilePathInjectionVulnerabilities", Justification = "Path is constructed from validated GUID and sanitized podcast title")] public async Task GetPodcastImage( [FromRoute] Guid podcastId, CancellationToken cancellationToken) { try { var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false); if (podcast == null) { return NotFound("Podcast not found"); } // Try to serve local cached image first var config = Plugin.Instance?.Configuration; if (config?.CreatePodcastFolders == true) { var basePath = _storageService.GetStoragePath(); var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title)); var artworkPath = Path.Combine(podcastFolder, "folder.jpg"); if (System.IO.File.Exists(artworkPath)) { var fileStream = System.IO.File.OpenRead(artworkPath); return File(fileStream, "image/jpeg"); } } // Fall back to proxying the external URL if (!string.IsNullOrEmpty(podcast.ImageUrl)) { return await ProxyImageAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false); } return NotFound("No image available"); } catch (Exception ex) { _logger.LogError(ex, "Error serving podcast image for {PodcastId}", podcastId); return StatusCode(500, "Failed to serve podcast image"); } } /// /// Gets the image for an episode. /// /// The podcast ID. /// The episode ID. /// Cancellation token. /// The image file. [HttpGet("episode/{podcastId}/{episodeId}")] [AllowAnonymous] [SuppressMessage("Microsoft.Security", "CA3003:ReviewCodeForFilePathInjectionVulnerabilities", Justification = "Path is constructed from validated GUIDs and sanitized podcast title")] public async Task GetEpisodeImage( [FromRoute] Guid podcastId, [FromRoute] Guid episodeId, CancellationToken cancellationToken) { try { var podcast = await _storageService.GetPodcastAsync(podcastId).ConfigureAwait(false); if (podcast == null) { return NotFound("Podcast not found"); } var episode = podcast.Episodes.FirstOrDefault(e => e.Id == episodeId); if (episode == null) { return NotFound("Episode not found"); } // Try to serve local cached episode image first var config = Plugin.Instance?.Configuration; if (config?.CreatePodcastFolders == true) { var basePath = _storageService.GetStoragePath(); var podcastFolder = Path.Combine(basePath, SanitizeFileName(podcast.Title)); var episodeFileName = $"{SanitizeFileName(episode.Id.ToString())}.jpg"; var artworkPath = Path.Combine(podcastFolder, episodeFileName); if (System.IO.File.Exists(artworkPath)) { var fileStream = System.IO.File.OpenRead(artworkPath); return File(fileStream, "image/jpeg"); } } // Fall back to episode ImageUrl if available if (!string.IsNullOrEmpty(episode.ImageUrl)) { return await ProxyImageAsync(episode.ImageUrl, cancellationToken).ConfigureAwait(false); } // Final fallback to podcast image if (!string.IsNullOrEmpty(podcast.ImageUrl)) { return await ProxyImageAsync(podcast.ImageUrl, cancellationToken).ConfigureAwait(false); } return NotFound("No image available"); } catch (Exception ex) { _logger.LogError(ex, "Error serving episode image for {EpisodeId}", episodeId); return StatusCode(500, "Failed to serve episode image"); } } private async Task ProxyImageAsync(string imageUrl, CancellationToken cancellationToken) { try { var client = _httpClientFactory.CreateClient("Jellypod"); var response = await client.GetAsync(imageUrl, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) { _logger.LogWarning("Failed to fetch image: {StatusCode} from {Url}", response.StatusCode, imageUrl); return StatusCode((int)response.StatusCode); } var contentType = response.Content.Headers.ContentType?.MediaType ?? "image/jpeg"; var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); return File(stream, contentType); } catch (Exception ex) { _logger.LogError(ex, "Error proxying image from {Url}", imageUrl); return StatusCode(500, "Failed to proxy image"); } } private static string SanitizeFileName(string name) { var invalidChars = Path.GetInvalidFileNameChars(); return string.Join("_", name.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries)).TrimEnd('.'); } }