Duncan Tourolle ac6a3842dd
Some checks failed
🏗️ Build Plugin / call (push) Failing after 0s
📝 Create/Update Release Draft & Release Bump PR / call (push) Failing after 0s
🧪 Test Plugin / call (push) Failing after 0s
🔬 Run CodeQL / call (push) Failing after 0s
first commit
2025-11-12 22:05:36 +01:00

508 lines
21 KiB
C#

using System;
using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.SRFPlay.Api.Models;
using Jellyfin.Plugin.SRFPlay.Configuration;
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 const string BaseUrl = "https://il.srgssr.ch/integrationlayer/2.0";
private const string PlayV3BaseUrlTemplate = "https://www.{0}.ch/play/v3/api/{0}/production/";
private static readonly System.Text.CompositeFormat PlayV3UrlFormat = System.Text.CompositeFormat.Parse(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(BaseUrl);
_playV3HttpClient = CreateHttpClient(null);
_jsonOptions = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
}
/// <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");
_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 url = $"/mediaComposition/byUrn/{urn}.json";
var fullUrl = $"{BaseUrl}{url}";
_logger.LogInformation("Fetching media composition for URN: {Urn} from {Url}", urn, fullUrl);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
// Log response headers to diagnose geo-blocking
var xLocation = response.Headers.Contains("x-location")
? string.Join(", ", response.Headers.GetValues("x-location"))
: "not present";
_logger.LogInformation(
"Media composition response for URN {Urn}: StatusCode={StatusCode}, x-location={XLocation}",
urn,
response.StatusCode,
xLocation);
// If HttpClient fails, try curl as fallback
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("HttpClient failed with {StatusCode}, trying curl fallback", response.StatusCode);
return await FetchWithCurlAsync(fullUrl, cancellationToken).ConfigureAwait(false);
}
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
if (result?.ChapterList != null && result.ChapterList.Count > 0)
{
_logger.LogInformation(
"Successfully fetched media composition for URN: {Urn} - Chapters: {ChapterCount}",
urn,
result.ChapterList.Count);
}
else
{
_logger.LogWarning("Media composition for URN {Urn} has no chapters", urn);
}
return result;
}
catch (HttpRequestException ex)
{
_logger.LogError(
ex,
"HTTP error fetching media composition for URN: {Urn} - StatusCode: {StatusCode}, trying curl fallback",
urn,
ex.StatusCode);
var fullUrl = $"{BaseUrl}/mediaComposition/byUrn/{urn}.json";
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
};
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?.ChapterList != null && result.ChapterList.Count > 0)
{
_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 async Task<MediaComposition?> GetLatestVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var url = $"/video/{businessUnit}/latest.json";
_logger.LogInformation("Fetching latest videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Latest videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Latest videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched latest videos for business unit: {BusinessUnit}", businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching latest videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <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 async Task<MediaComposition?> GetTrendingVideosAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var url = $"/video/{businessUnit}/trending.json";
_logger.LogInformation("Fetching trending videos for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Trending videos API response: {StatusCode}", response.StatusCode);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogDebug("Trending videos response length: {Length}", content.Length);
var result = JsonSerializer.Deserialize<MediaComposition>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched trending videos for business unit: {BusinessUnit}", businessUnit);
return result;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching trending videos for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <summary>
/// Gets raw JSON response from a URL.
/// </summary>
/// <param name="url">The relative URL.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The JSON string.</returns>
public async Task<string?> GetJsonAsync(string url, CancellationToken cancellationToken = default)
{
try
{
_logger.LogDebug("Fetching JSON from URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
return content;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching JSON from URL: {Url}", url);
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 async Task<System.Collections.Generic.List<PlayV3Show>?> GetAllShowsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}shows";
_logger.LogInformation("Fetching all shows for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Show>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} shows for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching shows for business unit: {BusinessUnit}", businessUnit);
return null;
}
}
/// <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 async Task<System.Collections.Generic.List<PlayV3Topic>?> GetAllTopicsAsync(string businessUnit, CancellationToken cancellationToken = default)
{
try
{
var baseUrl = string.Format(CultureInfo.InvariantCulture, PlayV3UrlFormat, businessUnit);
var url = $"{baseUrl}topics";
_logger.LogInformation("Fetching all topics for business unit: {BusinessUnit} from URL: {Url}", businessUnit, url);
var response = await _playV3HttpClient.GetAsync(url, cancellationToken).ConfigureAwait(false);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
var result = JsonSerializer.Deserialize<PlayV3DirectResponse<PlayV3Topic>>(content, _jsonOptions);
_logger.LogInformation("Successfully fetched {Count} topics for business unit: {BusinessUnit}", result?.Data?.Count ?? 0, businessUnit);
return result?.Data;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error fetching topics for business unit: {BusinessUnit}", 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 response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
_logger.LogError("API returned error {StatusCode}: {Error}", response.StatusCode, errorContent);
return null;
}
var content = await response.Content.ReadAsStringAsync(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;
}
}
/// <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();
}
}
}