508 lines
21 KiB
C#
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();
|
|
}
|
|
}
|
|
}
|