Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 76714eb0c6 | |||
| f48aa86256 | |||
|
|
5a908cbe4d |
189
Jellyfin.Plugin.Jellypod/Api/ImageController.cs
Normal file
189
Jellyfin.Plugin.Jellypod/Api/ImageController.cs
Normal file
@ -0,0 +1,189 @@
|
||||
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('.');
|
||||
}
|
||||
}
|
||||
@ -119,6 +119,12 @@ public class JellypodController : ControllerBase
|
||||
// Download podcast artwork
|
||||
await _downloadService.DownloadPodcastArtworkAsync(podcast).ConfigureAwait(false);
|
||||
|
||||
// Download episode artwork for all initial episodes
|
||||
foreach (var episode in podcast.Episodes)
|
||||
{
|
||||
await _downloadService.DownloadEpisodeArtworkAsync(podcast, episode).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Added podcast: {Title} ({Url})", podcast.Title, podcast.FeedUrl);
|
||||
|
||||
return CreatedAtAction(nameof(GetPodcast), new { id = podcast.Id }, podcast);
|
||||
@ -230,6 +236,12 @@ public class JellypodController : ControllerBase
|
||||
podcast.LastUpdated = DateTime.UtcNow;
|
||||
await _storageService.UpdatePodcastAsync(podcast).ConfigureAwait(false);
|
||||
|
||||
// Download artwork for new episodes
|
||||
foreach (var episode in newEpisodes)
|
||||
{
|
||||
await _downloadService.DownloadEpisodeArtworkAsync(podcast, episode).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return Ok(podcast);
|
||||
}
|
||||
|
||||
|
||||
@ -189,7 +189,7 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
||||
Id = podcast.Id.ToString("N"),
|
||||
Name = podcast.Title,
|
||||
Overview = podcast.Description,
|
||||
ImageUrl = podcast.ImageUrl,
|
||||
ImageUrl = GetPodcastImageUrl(podcast.Id),
|
||||
Type = ChannelItemType.Folder,
|
||||
FolderType = ChannelFolderType.Container,
|
||||
DateCreated = podcast.DateAdded,
|
||||
@ -257,7 +257,7 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
||||
Id = episode.Id.ToString("N"),
|
||||
Name = episodeName,
|
||||
Overview = overview,
|
||||
ImageUrl = episode.ImageUrl ?? podcast.ImageUrl,
|
||||
ImageUrl = GetEpisodeImageUrl(podcast.Id, episode.Id),
|
||||
Type = ChannelItemType.Media,
|
||||
ContentType = ChannelMediaContentType.Podcast,
|
||||
MediaType = ChannelMediaType.Audio,
|
||||
@ -497,4 +497,16 @@ public class JellypodChannel : IChannel, IHasCacheKey, IRequiresMediaInfoCallbac
|
||||
ReadAtNativeFramerate = false
|
||||
};
|
||||
}
|
||||
|
||||
private string GetPodcastImageUrl(Guid podcastId)
|
||||
{
|
||||
var localAddress = _appHost.GetApiUrlForLocalAccess();
|
||||
return $"{localAddress}/Jellypod/Image/podcast/{podcastId:N}";
|
||||
}
|
||||
|
||||
private string GetEpisodeImageUrl(Guid podcastId, Guid episodeId)
|
||||
{
|
||||
var localAddress = _appHost.GetApiUrlForLocalAccess();
|
||||
return $"{localAddress}/Jellypod/Image/episode/{podcastId:N}/{episodeId:N}";
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,4 +46,13 @@ public interface IPodcastDownloadService
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task representing the download operation.</returns>
|
||||
Task DownloadPodcastArtworkAsync(Podcast podcast, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Downloads episode artwork.
|
||||
/// </summary>
|
||||
/// <param name="podcast">The podcast.</param>
|
||||
/// <param name="episode">The episode.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task representing the download operation.</returns>
|
||||
Task DownloadEpisodeArtworkAsync(Podcast podcast, Episode episode, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
@ -283,6 +283,46 @@ public sealed class PodcastDownloadService : IPodcastDownloadService, IDisposabl
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DownloadEpisodeArtworkAsync(Podcast podcast, Episode episode, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(episode.ImageUrl))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config?.CreatePodcastFolders != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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 (File.Exists(artworkPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(podcastFolder);
|
||||
|
||||
var httpClient = _httpClientFactory.CreateClient("Jellypod");
|
||||
var imageBytes = await httpClient.GetByteArrayAsync(episode.ImageUrl, cancellationToken).ConfigureAwait(false);
|
||||
await File.WriteAllBytesAsync(artworkPath, imageBytes, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Downloaded artwork for episode: {Title}", episode.Title);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to download artwork for episode: {Title}", episode.Title);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ProcessQueueAsync()
|
||||
{
|
||||
try
|
||||
|
||||
@ -8,6 +8,14 @@
|
||||
"category": "General",
|
||||
"imageUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/raw/branch/master/Jellyfin.Plugin.Jellypod/Images/channel-icon.jpg",
|
||||
"versions": [
|
||||
{
|
||||
"version": "1.0.2",
|
||||
"changelog": "Release 1.0.2",
|
||||
"targetAbi": "10.9.0.0",
|
||||
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jellypod/releases/download/v1.0.2/jellypod_1.0.2.0.zip",
|
||||
"checksum": "874f6b76c8cf4bac6495fe946224096a",
|
||||
"timestamp": "2025-12-30T15:25:33Z"
|
||||
},
|
||||
{
|
||||
"version": "1.0.1",
|
||||
"changelog": "Release 1.0.1",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user