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;
///
/// HTTP client for interacting with the SRF Integration Layer API.
///
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;
///
/// Initializes a new instance of the class.
///
/// The logger factory.
public SRFApiClient(ILoggerFactory loggerFactory)
{
_logger = loggerFactory.CreateLogger();
_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
};
}
///
/// Creates an HttpClient with optional proxy configuration.
///
/// The base address for the HTTP client.
/// Configured HttpClient.
[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;
}
///
/// Gets media composition by URN using curl as a fallback.
///
/// The URN of the content.
/// The cancellation token.
/// The media composition.
public async Task 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(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;
}
}
///
/// Fetches content using curl command as a fallback.
///
/// The full URL to fetch.
/// The cancellation token.
/// The deserialized media composition.
private async Task 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(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;
}
}
///
/// Gets the latest videos for a business unit.
///
/// The business unit (e.g., srf, rts).
/// The cancellation token.
/// The media composition containing latest videos.
public async Task 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(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;
}
}
///
/// Gets the trending videos for a business unit.
///
/// The business unit (e.g., srf, rts).
/// The cancellation token.
/// The media composition containing trending videos.
public async Task 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(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;
}
}
///
/// Gets raw JSON response from a URL.
///
/// The relative URL.
/// The cancellation token.
/// The JSON string.
public async Task 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;
}
}
///
/// Gets all shows from the Play v3 API.
///
/// The business unit (e.g., srf, rts).
/// The cancellation token.
/// List of shows.
public async Task?> 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>(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;
}
}
///
/// Gets all topics from the Play v3 API.
///
/// The business unit (e.g., srf, rts).
/// The cancellation token.
/// List of topics.
public async Task?> 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>(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;
}
}
///
/// Gets videos for a specific show from the Play v3 API.
///
/// The business unit (e.g., srf, rts).
/// The show ID.
/// The cancellation token.
/// List of videos.
public async Task?> 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>(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;
}
}
///
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
///
/// Releases the unmanaged resources and optionally releases the managed resources.
///
/// True to release both managed and unmanaged resources; false to release only unmanaged resources.
protected virtual void Dispose(bool disposing)
{
if (disposing)
{
_httpClient?.Dispose();
_playV3HttpClient?.Dispose();
}
}
}