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(); } } }