Duncan Tourolle 7c76402a4a
Some checks failed
🏗️ Build Plugin / build (push) Failing after 9s
🧪 Test Plugin / test (push) Successful in 1m28s
🚀 Release Plugin / build-and-release (push) Failing after 5s
Clean up dead code, consolidate duplication, fix redundancies
Remove 9 dead methods, 6 unused constants, and redundant
ReaderWriterLockSlim from MetadataCache. Consolidate repeated
patterns into HasChapters, IsPlayable, and ToLowerString helpers.
Extract shared API methods in SRFApiClient. Move variant manifest
rewriting from controller to StreamProxyService. Make Auto quality
distinct from HD. Update README architecture section.
2026-02-28 11:34:45 +01:00

453 lines
20 KiB
C#

using System;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Api.Models.PlayV3;
using Jellyfin.Plugin.SRFPlay.Configuration;
using Jellyfin.Plugin.SRFPlay.Constants;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.SRFPlay.Api;
/// <summary>
/// HTTP client for interacting with the SRF Integration Layer API.
/// </summary>
public class SRFApiClient : IDisposable
{
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(ApiEndpoints.PlayV3BaseUrlTemplate);
private readonly HttpClient _httpClient;
private readonly HttpClient _playV3HttpClient;
private readonly ILogger _logger;
private readonly JsonSerializerOptions _jsonOptions;
private readonly PluginConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the <see cref="SRFApiClient"/> class.
/// </summary>
/// <param name="loggerFactory">The logger factory.</param>
public SRFApiClient(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger<SRFApiClient>();
_configuration = Plugin.Instance?.Configuration ?? new PluginConfiguration();
// Log proxy configuration status
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
{
_logger.LogInformation(
"SRFApiClient initializing with proxy enabled: {ProxyAddress}",
_configuration.ProxyAddress);
}
else
{
_logger.LogInformation("SRFApiClient initializing without proxy");
}
_httpClient = CreateHttpClient(ApiEndpoints.IntegrationLayerBaseUrl);
_playV3HttpClient = CreateHttpClient(null);
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};
}
/// <summary>
/// Reads HTTP response content as UTF-8 string.
/// </summary>
/// <param name="content">The HTTP content to read.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The content as a UTF-8 decoded string.</returns>
private async Task<string> ReadAsUtf8StringAsync(HttpContent content, CancellationToken cancellationToken = default)
{
var bytes = await content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
return Encoding.UTF8.GetString(bytes);
}
/// <summary>
/// Creates an HttpClient with optional proxy configuration.
/// </summary>
/// <param name="baseAddress">The base address for the HTTP client.</param>
/// <returns>Configured HttpClient.</returns>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Security", "CA5399:HttpClient is created without enabling CheckCertificateRevocationList", Justification = "CRL check disabled for proxy compatibility")]
private HttpClient CreateHttpClient(string? baseAddress)
{
HttpClientHandler handler = new HttpClientHandler
{
CheckCertificateRevocationList = false, // Disable CRL check for proxy compatibility
AutomaticDecompression = System.Net.DecompressionMethods.All
};
// Configure proxy if enabled
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
{
try
{
var proxy = new WebProxy(_configuration.ProxyAddress);
// Add credentials if provided
if (!string.IsNullOrWhiteSpace(_configuration.ProxyUsername))
{
proxy.Credentials = new NetworkCredential(
_configuration.ProxyUsername,
_configuration.ProxyPassword);
}
handler.Proxy = proxy;
handler.UseProxy = true;
_logger.LogInformation(
"Proxy configured: {ProxyAddress} (Authentication: {HasAuth})",
_configuration.ProxyAddress,
!string.IsNullOrWhiteSpace(_configuration.ProxyUsername));
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to configure proxy: {ProxyAddress}", _configuration.ProxyAddress);
handler.UseProxy = false;
}
}
var client = new HttpClient(handler)
{
Timeout = TimeSpan.FromSeconds(30),
DefaultRequestVersion = HttpVersion.Version11, // Force HTTP/1.1
DefaultVersionPolicy = HttpVersionPolicy.RequestVersionExact // Ensure HTTP/1.1 is used, not upgraded
};
// Add browser headers - required by SRF API
client.DefaultRequestHeaders.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");
client.DefaultRequestHeaders.Accept.ParseAdd("*/*");
client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.9");
client.DefaultRequestHeaders.Add("Accept-Charset", "utf-8");
_logger.LogInformation(
"HttpClient created with HTTP/1.1 and headers - User-Agent: {UserAgent}, Accept: {Accept}, Accept-Language: {AcceptLanguage}",
client.DefaultRequestHeaders.UserAgent.ToString(),
client.DefaultRequestHeaders.Accept.ToString(),
client.DefaultRequestHeaders.AcceptLanguage.ToString());
if (!string.IsNullOrWhiteSpace(baseAddress))
{
client.BaseAddress = new Uri(baseAddress);
}
return client;
}
/// <summary>
/// Gets media composition by URN using curl as a fallback.
/// </summary>
/// <param name="urn">The URN of the content.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition.</returns>
public async Task<MediaComposition?> GetMediaCompositionByUrnAsync(string urn, CancellationToken cancellationToken = default)
{
try
{
var fullUrl = $"{ApiEndpoints.IntegrationLayerBaseUrl}/mediaComposition/byUrn/{urn}.json";
_logger.LogDebug("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
// Use curl - HttpClient returns 404 due to server routing/network configuration
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching media composition for URN: {Urn}", urn);
return null;
}
}
/// <summary>
/// Fetches content using curl command as a fallback.
/// </summary>
/// <param name="url">The full URL to fetch.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The deserialized media composition.</returns>
private async Task<MediaComposition?> FetchWithCurlAsync(string url, CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("Using curl to fetch: {Url}", url);
var curlArgs = $"-s -H \"User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36\"";
// Add proxy if configured
if (_configuration.UseProxy && !string.IsNullOrWhiteSpace(_configuration.ProxyAddress))
{
curlArgs += $" -x {_configuration.ProxyAddress}";
if (!string.IsNullOrWhiteSpace(_configuration.ProxyUsername))
{
curlArgs += $" -U {_configuration.ProxyUsername}:{_configuration.ProxyPassword}";
}
_logger.LogInformation("curl using proxy: {ProxyAddress}", _configuration.ProxyAddress);
}
curlArgs += $" \"{url}\"";
var processStartInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = "curl",
Arguments = curlArgs,
RedirectStandardOutput = true,
RedirectStandardError = true,
UseShellExecute = false,
CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8
};
using var process = new System.Diagnostics.Process { StartInfo = processStartInfo };
process.Start();
var output = await process.StandardOutput.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
var error = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
if (process.ExitCode != 0)
{
_logger.LogError("curl failed with exit code {ExitCode}: {Error}", process.ExitCode, error);
return null;
}
if (string.IsNullOrWhiteSpace(output))
{
_logger.LogWarning("curl returned empty response");
return null;
}
_logger.LogInformation("curl succeeded, response length: {Length}", output.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(output, _jsonOptions);
if (result?.HasChapters == true)
{
_logger.LogInformation("Successfully fetched media composition via curl - Chapters: {ChapterCount}", result.ChapterList.Count);
}
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error using curl to fetch URL: {Url}", url);
return null;
}
}
/// <summary>
/// Gets the latest videos for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing latest videos.</returns>
public Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "latest", cancellationToken);
/// <summary>
/// Gets the trending videos for a business unit.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The media composition containing trending videos.</returns>
public Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetMediaCompositionListAsync(businessUnit, "trending", cancellationToken);
private async Task<MediaComposition?> GetMediaCompositionListAsync(string businessUnit, string endpoint, CancellationToken cancellationToken)
{
try
{
var url = $"/video/{businessUnit}/{endpoint}.json";
_logger.LogInformation("Fetching {Endpoint} videos for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("{Endpoint} videos API response: {StatusCode}", endpoint, response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("{Endpoint} videos response length: {Length}", endpoint, content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching {Endpoint} videos for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
/// <summary>
/// Gets all shows from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of shows.</returns>
public Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Show>(businessUnit, "shows", cancellationToken);
/// <summary>
/// Gets all topics from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of topics.</returns>
public Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
=> GetPlayV3DirectListAsync<PlayV3Topic>(businessUnit, "topics", cancellationToken);
private async Task<System.Collections.Generic.List<T>?> GetPlayV3DirectListAsync<T>(string businessUnit, string endpoint, CancellationToken cancellationToken)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}{endpoint}";
_logger.LogInformation("Fetching all {Endpoint} for business unit: {BusinessUnit} from URL: {Url}", endpoint, businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<T>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} {Endpoint} for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, endpoint, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching {Endpoint} for business unit: {BusinessUnit}", endpoint, businessUnit);
return null;
}
}
/// <summary>
/// Gets videos for a specific show from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="showId">The show ID.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of videos.</returns>
public async Task<System.Collections.Generic.List<PlayV3Video>?> GetVideosForShowAsync(string businessUnit, string showId, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}videos-by-show-id?showId={showId}";
_logger.LogDebug("Fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3Response<PlayV3Video>>(content, _jsonOptions);
_logger.LogDebug("Successfully fetched {Count} videos for show {ShowId}", result?.Data?.Data?.Count ?? 0, showId);
return result?.Data?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching videos for show {ShowId} from business unit: {BusinessUnit}", showId, businessUnit);
return null;
}
}
/// <summary>
/// Gets scheduled livestreams for sports or news events from the Play v3 API.
/// </summary>
/// <param name="businessUnit">The business unit (e.g., srf, rts).</param>
/// <param name="eventType">The event type (SPORT or NEWS).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>List of scheduled livestream entries.</returns>
public async Task<System.Collections.Generic.IReadOnlyList<PlayV3TvProgram>?> GetScheduledLivestreamsAsync(
string businessUnit,
string eventType = "SPORT",
CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}livestreams?eventType={eventType.ToUpperInvariant()}";
_logger.LogInformation("Fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await ReadAsUtf8StringAsync(response.Content, cancellationToken).ConfigureAwait(false);
// The response structure is: { "data": { "scheduledLivestreams": [...] } }
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(content, _jsonOptions);
if (jsonDoc.TryGetProperty("data", out var dataElement) &&
dataElement.TryGetProperty("scheduledLivestreams", out var livestreamsElement))
{
var livestreams = JsonSerializer.Deserialize<System.Collections.Generic.List<PlayV3TvProgram>>(
livestreamsElement.GetRawText(),
_jsonOptions);
_logger.LogInformation("Successfully fetched {Count} scheduled livestreams for eventType={EventType}", livestreams?.Count ?? 0, eventType);
return livestreams;
}
_logger.LogWarning("No scheduledLivestreams found in response");
return null;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching scheduled livestreams for eventType={EventType} from business unit: {BusinessUnit}", eventType, businessUnit);
return null;
}
}
/// <inheritdoc/>
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the unmanaged resources and optionally releases the managed resources.
/// </summary>
/// <param name="disposing">True to release both managed and unmanaged resources; false to release only unmanaged resources.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_httpClient?.Dispose();
_playV3HttpClient?.Dispose();
}
}
}