From 14b6c335422b6c322c45566b4434948ca8b3b1f7 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sat, 6 Dec 2025 17:34:17 +0100 Subject: [PATCH] Proxy images to fix broken meta-data --- .../Channels/SRFPlayChannel.cs | 29 +++- .../Controllers/StreamProxyController.cs | 124 +++++++++++++++++- .../Providers/SRFImageProvider.cs | 38 ++++-- 3 files changed, 177 insertions(+), 14 deletions(-) diff --git a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs index 3c19588..2d84f2b 100644 --- a/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs +++ b/Jellyfin.Plugin.SRFPlay/Channels/SRFPlayChannel.cs @@ -489,18 +489,21 @@ public class SRFPlayChannel : IChannel, IHasCacheKey var overview = chapter.Description ?? chapter.Lead; // Get image URL - prefer chapter image, fall back to show image if available - var imageUrl = chapter.ImageUrl; - if (string.IsNullOrEmpty(imageUrl) && mediaComposition.Show != null) + var originalImageUrl = chapter.ImageUrl; + if (string.IsNullOrEmpty(originalImageUrl) && mediaComposition.Show != null) { - imageUrl = mediaComposition.Show.ImageUrl; + originalImageUrl = mediaComposition.Show.ImageUrl; _logger.LogDebug("URN {Urn}: Using show image as fallback", urn); } - if (string.IsNullOrEmpty(imageUrl)) + if (string.IsNullOrEmpty(originalImageUrl)) { _logger.LogWarning("URN {Urn}: No image URL available for '{Title}'", urn, chapter.Title); } + // Proxy image URL to fix Content-Type headers from SRF CDN + var imageUrl = CreateProxiedImageUrl(originalImageUrl, serverUrl); + // Use ValidFrom for premiere date if this is a scheduled livestream, otherwise use Date var premiereDate = chapter.Type == "SCHEDULED_LIVESTREAM" ? chapter.ValidFrom?.ToUniversalTime() : chapter.Date?.ToUniversalTime(); @@ -617,6 +620,24 @@ public class SRFPlayChannel : IChannel, IHasCacheKey } #pragma warning restore CA5351 + /// + /// Creates a proxy URL for an image to fix Content-Type headers from SRF CDN. + /// + /// The original SRF image URL. + /// The server base URL. + /// The proxied image URL, or null if original is null. + private static string? CreateProxiedImageUrl(string? originalUrl, string serverUrl) + { + if (string.IsNullOrEmpty(originalUrl)) + { + return null; + } + + // Encode the original URL as base64 for safe transport + var encodedUrl = Convert.ToBase64String(Encoding.UTF8.GetBytes(originalUrl)); + return $"{serverUrl}/Plugins/SRFPlay/Proxy/Image?url={encodedUrl}"; + } + /// public bool IsEnabledFor(string userId) { diff --git a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs index e52c986..84b01d5 100644 --- a/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs +++ b/Jellyfin.Plugin.SRFPlay/Controllers/StreamProxyController.cs @@ -1,8 +1,11 @@ using System; using System.Globalization; +using System.Net.Http; +using System.Net.Http.Headers; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.SRFPlay.Services.Interfaces; +using MediaBrowser.Common.Net; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; @@ -19,16 +22,22 @@ public class StreamProxyController : ControllerBase { private readonly ILogger _logger; private readonly IStreamProxyService _proxyService; + private readonly IHttpClientFactory _httpClientFactory; /// /// Initializes a new instance of the class. /// /// The logger. /// The proxy service. - public StreamProxyController(ILogger logger, IStreamProxyService proxyService) + /// The HTTP client factory. + public StreamProxyController( + ILogger logger, + IStreamProxyService proxyService, + IHttpClientFactory httpClientFactory) { _logger = logger; _proxyService = proxyService; + _httpClientFactory = httpClientFactory; } /// @@ -316,4 +325,117 @@ public class StreamProxyController : ControllerBase return "application/octet-stream"; } + + /// + /// Proxies image requests from SRF CDN, fixing Content-Type headers. + /// + /// The original image URL (base64 encoded). + /// Cancellation token. + /// The image data with correct Content-Type. + [HttpGet("Image")] + [AllowAnonymous] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task GetImage( + [FromQuery] string url, + CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(url)) + { + return BadRequest("URL parameter is required"); + } + + string decodedUrl; + try + { + // Decode base64 URL + var bytes = Convert.FromBase64String(url); + decodedUrl = System.Text.Encoding.UTF8.GetString(bytes); + } + catch (FormatException) + { + _logger.LogWarning("Invalid base64 URL parameter: {Url}", url); + return BadRequest("Invalid URL encoding"); + } + + _logger.LogDebug("Proxying image from: {Url}", decodedUrl); + + try + { + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); + + // Create request with proper headers + var request = new HttpRequestMessage(HttpMethod.Get, new Uri(decodedUrl)); + request.Headers.UserAgent.ParseAdd( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"); + request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8"); + + var response = await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning( + "Image fetch failed with status {StatusCode} for {Url}", + response.StatusCode, + decodedUrl); + return NotFound(); + } + + var imageData = await response.Content.ReadAsByteArrayAsync(cancellationToken) + .ConfigureAwait(false); + + // Determine correct content type + var contentType = GetImageContentType(decodedUrl); + + _logger.LogDebug( + "Returning proxied image ({Length} bytes, {ContentType})", + imageData.Length, + contentType); + + return File(imageData, contentType); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Error fetching image from {Url}", decodedUrl); + return StatusCode(StatusCodes.Status502BadGateway); + } + } + + /// + /// Gets the content type for an image based on URL or file extension. + /// + /// The image URL. + /// The MIME content type. + private static string GetImageContentType(string url) + { + if (string.IsNullOrEmpty(url)) + { + return "image/jpeg"; + } + + var uri = new Uri(url); + var path = uri.AbsolutePath.ToLowerInvariant(); + + if (path.EndsWith(".png", StringComparison.Ordinal)) + { + return "image/png"; + } + + if (path.EndsWith(".gif", StringComparison.Ordinal)) + { + return "image/gif"; + } + + if (path.EndsWith(".webp", StringComparison.Ordinal)) + { + return "image/webp"; + } + + // Default to JPEG for SRF images (most common) + return "image/jpeg"; + } } diff --git a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs index 59f7b04..c4d176b 100644 --- a/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs +++ b/Jellyfin.Plugin.SRFPlay/Providers/SRFImageProvider.cs @@ -156,26 +156,46 @@ public class SRFImageProvider : IRemoteImageProvider, IHasOrder /// public async Task GetImageResponse(string url, CancellationToken cancellationToken) { + _logger.LogDebug("Fetching image from URL: {Url}", url); + var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); // Create request with proper headers - SRF CDN requires User-Agent var request = new HttpRequestMessage(HttpMethod.Get, new Uri(url)); request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"); - request.Headers.Accept.ParseAdd("image/*"); + request.Headers.Accept.ParseAdd("image/jpeg, image/png, image/webp, image/*;q=0.8"); var response = await httpClient.SendAsync(request, cancellationToken).ConfigureAwait(false); - // Fix Content-Type if it's binary/octet-stream - SRF CDN returns wrong content type + var originalContentType = response.Content.Headers.ContentType?.MediaType; + _logger.LogDebug( + "Image response status: {StatusCode}, Content-Type: {ContentType}, Content-Length: {Length}", + response.StatusCode, + originalContentType ?? "null", + response.Content.Headers.ContentLength); + + // Fix Content-Type if it's not a proper image type - SRF CDN often returns wrong content type // Jellyfin needs correct Content-Type to process images - if (response.IsSuccessStatusCode && - response.Content.Headers.ContentType?.MediaType == "binary/octet-stream") + if (response.IsSuccessStatusCode) { - // Determine correct content type from URL extension - var contentType = GetContentTypeFromUrl(url); - if (!string.IsNullOrEmpty(contentType)) + var needsContentTypeFix = string.IsNullOrEmpty(originalContentType) || + originalContentType == "binary/octet-stream" || + originalContentType == "application/octet-stream" || + !originalContentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase); + + if (needsContentTypeFix) { - _logger.LogDebug("Fixing Content-Type from binary/octet-stream to {ContentType} for {Url}", contentType, url); - response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + // Determine correct content type from URL extension or default to JPEG + var contentType = GetContentTypeFromUrl(url); + if (!string.IsNullOrEmpty(contentType)) + { + _logger.LogInformation( + "Fixing Content-Type from '{OriginalType}' to '{NewType}' for {Url}", + originalContentType ?? "null", + contentType, + url); + response.Content.Headers.ContentType = new MediaTypeHeaderValue(contentType); + } } }