190 lines
7.1 KiB
C#
190 lines
7.1 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Controller for serving podcast and episode images.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("Jellypod/Image")]
|
|
public class ImageController : ControllerBase
|
|
{
|
|
private readonly ILogger<ImageController> _logger;
|
|
private readonly IPodcastStorageService _storageService;
|
|
private readonly IHttpClientFactory _httpClientFactory;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="ImageController"/> class.
|
|
/// </summary>
|
|
/// <param name="logger">Logger instance.</param>
|
|
/// <param name="storageService">Storage service instance.</param>
|
|
/// <param name="httpClientFactory">HTTP client factory.</param>
|
|
public ImageController(
|
|
ILogger<ImageController> logger,
|
|
IPodcastStorageService storageService,
|
|
IHttpClientFactory httpClientFactory)
|
|
{
|
|
_logger = logger;
|
|
_storageService = storageService;
|
|
_httpClientFactory = httpClientFactory;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the image for a podcast.
|
|
/// </summary>
|
|
/// <param name="podcastId">The podcast ID.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The image file.</returns>
|
|
[HttpGet("podcast/{podcastId}")]
|
|
[AllowAnonymous]
|
|
[SuppressMessage("Microsoft.Security", "CA3003:ReviewCodeForFilePathInjectionVulnerabilities", Justification = "Path is constructed from validated GUID and sanitized podcast title")]
|
|
public async Task<IActionResult> 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");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the image for an episode.
|
|
/// </summary>
|
|
/// <param name="podcastId">The podcast ID.</param>
|
|
/// <param name="episodeId">The episode ID.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
/// <returns>The image file.</returns>
|
|
[HttpGet("episode/{podcastId}/{episodeId}")]
|
|
[AllowAnonymous]
|
|
[SuppressMessage("Microsoft.Security", "CA3003:ReviewCodeForFilePathInjectionVulnerabilities", Justification = "Path is constructed from validated GUIDs and sanitized podcast title")]
|
|
public async Task<IActionResult> 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<IActionResult> 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('.');
|
|
}
|
|
}
|