using System; using System.Collections.Generic; using System.Diagnostics; using System.Net; using System.Net.Http; using System.Net.Http.Json; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.Chaosflix.Api.Models; using Microsoft.Extensions.Logging; namespace Jellyfin.Plugin.Chaosflix.Api; /// /// HTTP client for the media.ccc.de public API with built-in caching. /// public class CccApiClient : IDisposable { /// Default public API endpoint; overridable for mirrors and tests. public const string DefaultBaseUrl = "https://api.media.ccc.de/public"; /// /// Default endpoint reporting which rooms are on air. A separate service from the /// archive API, hence its own base url. /// public const string DefaultStreamingBaseUrl = "https://streaming.media.ccc.de"; /// /// Name of the client used to resolve CDN redirects. /// It must be configured with AllowAutoRedirect = false. /// public const string RedirectClientName = "Chaosflix.CdnRedirect"; private const string ConferencesCacheKey = "conferences"; /// /// How long the conference list and conference details stay cached. /// rewrites both on every run, so this only has to /// outlive ; the extra hour of margin keeps /// the pre-warmed data usable when a run is late, skipped or still in progress. /// public static readonly TimeSpan ConferenceCacheTtl = TimeSpan.FromHours(7); /// Event detail with recordings — cache for 15 minutes. private static readonly TimeSpan EventTtl = TimeSpan.FromMinutes(15); /// Search results — cache for 10 minutes. private static readonly TimeSpan SearchTtl = TimeSpan.FromMinutes(10); /// /// Which rooms are live — cache for a minute. Everything else here describes an /// archive that changes by the day; this changes between two talks. /// private static readonly TimeSpan LiveTtl = TimeSpan.FromMinutes(1); /// /// How long the configuration page's live check waits. Short on purpose: an endpoint /// that needs longer than this is the answer the page is asking for. /// private static readonly TimeSpan ReachabilityTimeout = TimeSpan.FromSeconds(10); private readonly IHttpClientFactory _httpClientFactory; private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly MemoryCache _cache = new(); /// /// Initializes a new instance of the class. /// public CccApiClient(IHttpClientFactory httpClientFactory, ILogger logger) { _httpClientFactory = httpClientFactory; _httpClient = httpClientFactory.CreateClient(nameof(CccApiClient)); _httpClient.DefaultRequestHeaders.Accept.Add( new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json")); _logger = logger; } /// /// Gets all conferences (cached). /// public Task> GetConferencesAsync(CancellationToken cancellationToken) { return _cache.GetOrCreateAsync(ConferencesCacheKey, ConferenceCacheTtl, async ct => { _logger.LogDebug("Fetching conferences from CCC API"); var response = await _httpClient .GetFromJsonAsync(Url("/conferences"), ct) .ConfigureAwait(false); return response?.Conferences ?? new List(); }, cancellationToken); } /// /// Gets a single conference with its events (cached). /// public Task GetConferenceAsync(string acronym, CancellationToken cancellationToken) { return _cache.GetOrCreateAsync(ConferenceCacheKey(acronym), ConferenceCacheTtl, async ct => { _logger.LogDebug("Fetching conference {Acronym} from CCC API", acronym); return await _httpClient .GetFromJsonAsync(Url($"/conferences/{Uri.EscapeDataString(acronym)}"), ct) .ConfigureAwait(false); }, cancellationToken); } /// /// Refetches the conference list and replaces the cached copy, leaving every other /// cache entry (events, CDN redirects) untouched. /// public Task> RefreshConferencesAsync(CancellationToken cancellationToken) { _cache.Remove(ConferencesCacheKey); return GetConferencesAsync(cancellationToken); } /// /// Refetches a single conference and replaces the cached copy, leaving every other /// cache entry untouched. /// public Task RefreshConferenceAsync(string acronym, CancellationToken cancellationToken) { _cache.Remove(ConferenceCacheKey(acronym)); return GetConferenceAsync(acronym, cancellationToken); } /// /// Gets a single event with its recordings (cached). /// public Task GetEventAsync(string guid, CancellationToken cancellationToken) { return _cache.GetOrCreateAsync($"event:{guid}", EventTtl, async ct => { _logger.LogDebug("Fetching event {Guid} from CCC API", guid); try { return await _httpClient .GetFromJsonAsync(Url($"/events/{Uri.EscapeDataString(guid)}"), ct) .ConfigureAwait(false); } catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.NotFound) { _logger.LogDebug("Event {Guid} is unknown to the CCC API", guid); return null; } }, cancellationToken); } /// /// Gets the conferences that are streaming right now (cached briefly). /// Outside a congress the endpoint answers with an empty list, which is not an /// error and is what keeps the live folder from appearing all year. /// public Task> GetLiveConferencesAsync(CancellationToken cancellationToken) { // The endpoint is part of the key: pointing the plugin at a different streaming // site has to take effect at once, not after the entry expires. return _cache.GetOrCreateAsync($"live:{StreamingBaseUrl}", LiveTtl, async ct => { _logger.LogDebug("Fetching live streams from {Url}", StreamingBaseUrl); try { var conferences = await _httpClient .GetFromJsonAsync>($"{StreamingBaseUrl}/streams/v2.json", ct) .ConfigureAwait(false); return conferences ?? new List(); } catch (Exception ex) when (ex is HttpRequestException or System.Text.Json.JsonException) { // A congress is the one time the archive is least interesting, but a // streaming site that is down must not take the rest of the channel with it. _logger.LogWarning(ex, "Could not read the live streams; treating nothing as live"); return new List(); } }, cancellationToken); } /// /// Searches for events (cached). /// public Task> SearchEventsAsync(string query, CancellationToken cancellationToken) { return _cache.GetOrCreateAsync($"search:{query}", SearchTtl, async ct => { _logger.LogDebug("Searching CCC API for {Query}", query); var encoded = Uri.EscapeDataString(query); var response = await _httpClient .GetFromJsonAsync(Url($"/events/search?q={encoded}"), ct) .ConfigureAwait(false); return response?.Events ?? new List(); }, cancellationToken); } /// /// Gets the endpoint requests actually go to: the configured one, or the public default. /// public static string BaseUrl { get { var configured = Plugin.Instance?.Configuration.ApiBaseUrl; return string.IsNullOrWhiteSpace(configured) ? DefaultBaseUrl : configured.TrimEnd('/'); } } /// /// Gets the endpoint the live stream list is read from: the configured one, or the /// public default. /// public static string StreamingBaseUrl { get { var configured = Plugin.Instance?.Configuration.StreamingBaseUrl; return string.IsNullOrWhiteSpace(configured) ? DefaultStreamingBaseUrl : configured.TrimEnd('/'); } } private static string Url(string path) => BaseUrl + path; /// /// Asks the configured endpoint for the conference list, bypassing the cache, and /// reports whether it answered and how long it took. Used by the configuration page. /// internal async Task CheckReachabilityAsync(CancellationToken cancellationToken) { var url = Url("/conferences"); using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(ReachabilityTimeout); var started = Stopwatch.GetTimestamp(); try { using var response = await _httpClient.GetAsync(url, timeout.Token).ConfigureAwait(false); int? conferences = null; if (response.IsSuccessStatusCode) { var payload = await response.Content .ReadFromJsonAsync(timeout.Token).ConfigureAwait(false); conferences = payload?.Conferences?.Count ?? 0; } return new ReachabilityResult( response.IsSuccessStatusCode, (int)response.StatusCode, Elapsed(started), conferences, response.IsSuccessStatusCode ? null : response.ReasonPhrase, url); } catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { var reason = ex is OperationCanceledException ? $"no answer within {ReachabilityTimeout.TotalSeconds:N0}s" : ex.Message; _logger.LogWarning(ex, "Reachability check for {Url} failed", url); return new ReachabilityResult(false, null, Elapsed(started), null, reason, url); } } /// /// Counts what the cache currently holds, per kind of entry. /// internal CacheSnapshot GetCacheSnapshot() => new( _cache.CountLive(), _cache.CountLive(key => key == ConferencesCacheKey || key.StartsWith("conf:", StringComparison.Ordinal)), _cache.CountLive(key => key.StartsWith("event:", StringComparison.Ordinal)), _cache.CountLive(key => key.StartsWith("search:", StringComparison.Ordinal)), _cache.CountLive(key => key.StartsWith("redirect:", StringComparison.Ordinal)), _cache.Capacity, _cache.Hits, _cache.Misses); private static double Elapsed(long startedAt) => Math.Round(Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, 1); /// Outcome of . internal sealed record ReachabilityResult( bool Ok, int? StatusCode, double ElapsedMs, int? ConferenceCount, string? Error, string Url); /// What the API cache holds right now. internal sealed record CacheSnapshot( int Entries, int Conferences, int Events, int Searches, int Redirects, int Capacity, long Hits, long Misses); /// /// Clears the entire cache. /// public void ClearCache() => _cache.Clear(); /// /// Resolves a CDN URL by following redirects and returning the final URL. /// CCC CDN (cdn.media.ccc.de) does 302 redirects to mirror servers. /// Some clients (e.g. Android ExoPlayer) struggle with cross-domain redirects. /// public async Task ResolveRedirectAsync(string url, CancellationToken cancellationToken) { return await _cache.GetOrCreateAsync(RedirectCacheKey(url), TimeSpan.FromHours(2), async ct => { try { var client = _httpClientFactory.CreateClient(RedirectClientName); using var request = new HttpRequestMessage(HttpMethod.Head, new Uri(url)); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, ct) .ConfigureAwait(false); if ((int)response.StatusCode is >= 301 and <= 308 && response.Headers.Location is { } location) { var resolved = location.IsAbsoluteUri ? location.ToString() : new Uri(new Uri(url), location).ToString(); _logger.LogDebug("Resolved CDN redirect {Url} -> {Resolved}", url, resolved); return resolved; } } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug(ex, "Failed to resolve redirect for {Url}, using original", url); } return url; }, cancellationToken); } /// /// Forgets the cached mirror for so that the next resolution /// asks the CDN again. Used when the cached mirror stopped serving the recording. /// public void InvalidateRedirect(string url) => _cache.Remove(RedirectCacheKey(url)); private static string ConferenceCacheKey(string acronym) => $"conf:{acronym}"; private static string RedirectCacheKey(string url) => $"redirect:{url}"; /// public void Dispose() { _httpClient.Dispose(); GC.SuppressFinalize(this); } }