using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Chaosflix.Channel;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Chaosflix.Api;
///
/// Reports what the plugin currently believes to be true, for its configuration page:
/// whether the CCC API answers, when the sync task last ran and what the caches hold.
/// Nothing here is expensive unless it is asked for explicitly.
///
[ApiController]
[Route("api/[controller]")]
[Authorize(Policy = ElevationPolicy)]
public class ChaosflixStatusController : ControllerBase
{
///
/// Jellyfin's administrator policy. The constant lives in Jellyfin.Api, which plugins
/// do not reference, so the name is repeated here; the server registers it by name.
///
private const string ElevationPolicy = "RequiresElevation";
private static readonly HttpClient _probeClient = new() { Timeout = TimeSpan.FromSeconds(30) };
private readonly CccApiClient _apiClient;
private readonly ChaosflixChannel _channel;
private readonly ITaskManager _taskManager;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
public ChaosflixStatusController(
CccApiClient apiClient,
ChaosflixChannel channel,
ITaskManager taskManager,
ILogger logger)
{
_apiClient = apiClient;
_channel = channel;
_taskManager = taskManager;
_logger = logger;
}
///
/// Returns the plugin's state without touching the network.
///
[HttpGet]
public ActionResult GetStatus() => BuildStatus();
///
/// Asks the configured endpoint whether it answers, and how fast.
///
[HttpPost("CheckApi")]
public async Task> CheckApi()
{
var result = await _apiClient.CheckReachabilityAsync(HttpContext.RequestAborted).ConfigureAwait(false);
return new ApiCheck(
result.Ok,
result.Url,
result.StatusCode,
result.ElapsedMs,
result.ConferenceCount,
result.Error);
}
///
/// Empties every cache the plugin keeps, along the same path a configuration change
/// takes, and returns the state afterwards.
///
[HttpPost("ClearCaches")]
public ActionResult ClearCaches()
{
var before = BuildStatus();
_apiClient.ClearCache();
_channel.ClearProbeCache();
_channel.InvalidateCachedMediaSources();
_logger.LogInformation(
"Caches cleared from the configuration page: {Api} api entries, {Probe} probes",
before.ApiCache.Entries,
before.ProbeCache.Entries);
return BuildStatus();
}
///
/// Resolves one talk the whole way the channel does — API lookup, recording choice,
/// signed proxy url, proxy request, probe — and reports the stage that fails.
///
/// A talk guid, slug, channel item id or media.ccc.de url.
[HttpPost("CheckTalk")]
public async Task> CheckTalk([FromQuery] string? id)
{
var guid = NormalizeTalkId(id);
if (string.IsNullOrEmpty(guid))
{
return BadRequest("Give a talk guid, slug or media.ccc.de url.");
}
var cancellationToken = HttpContext.RequestAborted;
var stages = new List();
var started = Stopwatch.GetTimestamp();
var cccEvent = await _apiClient.GetEventAsync(guid, cancellationToken).ConfigureAwait(false);
if (cccEvent?.Recordings is null || cccEvent.Recordings.Count == 0)
{
stages.Add(new TalkCheckStage(
"API lookup",
false,
cccEvent is null
? $"{CccApiClient.BaseUrl}/events/{guid} returned no event"
: "the event has no recordings",
Elapsed(started)));
return Report(guid, null, stages);
}
stages.Add(new TalkCheckStage(
"API lookup",
true,
$"{cccEvent.Title} — {cccEvent.Recordings.Count} recordings",
Elapsed(started)));
started = Stopwatch.GetTimestamp();
var sources = (await _channel
.GetChannelItemMediaInfo($"event:{guid}", cancellationToken)
.ConfigureAwait(false))
.ToList();
if (sources.Count == 0)
{
stages.Add(new TalkCheckStage(
"Recording choice",
false,
"no recording survived the configured quality, format and language preferences",
Elapsed(started)));
return Report(guid, cccEvent.Title, stages);
}
var chosen = sources[0];
stages.Add(new TalkCheckStage(
"Recording choice",
true,
$"{chosen.Name} ({chosen.Container}), {sources.Count} versions offered",
Elapsed(started)));
var proxyUrl = chosen.Path ?? string.Empty;
var signed = ProxyUrl.IsSignedCorrectly(proxyUrl);
stages.Add(new TalkCheckStage(
"Signed proxy url",
signed,
signed
? ProxyUrl.Redact(proxyUrl)
: $"the url this plugin just produced does not carry a valid signature: {ProxyUrl.Redact(proxyUrl)}",
0));
if (!signed)
{
return Report(guid, cccEvent.Title, stages);
}
started = Stopwatch.GetTimestamp();
var proxy = await HeadProxyAsync(proxyUrl, cancellationToken).ConfigureAwait(false);
stages.Add(proxy with { ElapsedMs = Elapsed(started) });
if (!proxy.Ok)
{
return Report(guid, cccEvent.Title, stages);
}
var streams = chosen.MediaStreams ?? new List();
stages.Add(new TalkCheckStage(
"Probe",
streams.Count > 0,
streams.Count > 0
? string.Join(", ", streams.Select(s => $"{s.Type}@{s.Index} {s.Codec}"))
: "ffprobe reported no streams for the chosen recording",
0));
return Report(guid, cccEvent.Title, stages);
}
private static TalkCheck Report(string guid, string? title, List stages)
{
var failed = stages.FirstOrDefault(s => !s.Ok);
return new TalkCheck(failed is null, guid, title, failed?.Name, stages);
}
private async Task HeadProxyAsync(string proxyUrl, CancellationToken cancellationToken)
{
try
{
using var request = new HttpRequestMessage(HttpMethod.Head, proxyUrl);
using var response = await _probeClient
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
var size = response.Content.Headers.ContentLength;
var length = size.HasValue
? string.Create(CultureInfo.InvariantCulture, $"{size.Value / 1048576.0:N1} MiB")
: "no length announced";
return new TalkCheckStage(
"Proxy",
response.IsSuccessStatusCode,
response.IsSuccessStatusCode
? $"{(int)response.StatusCode} {response.Content.Headers.ContentType}, {length}"
: $"the proxy answered {(int)response.StatusCode} {response.ReasonPhrase}",
0);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Talk check could not reach the proxy");
return new TalkCheckStage("Proxy", false, ex.Message, 0);
}
}
private PluginStatus BuildStatus()
{
var cache = _apiClient.GetCacheSnapshot();
var probe = _channel.GetProbeCacheStats();
return new PluginStatus(
Plugin.Instance?.Version?.ToString() ?? "unknown",
CccApiClient.BaseUrl,
string.IsNullOrWhiteSpace(Plugin.Instance?.Configuration.ApiBaseUrl),
SyncStatus(),
new CacheStatus(
cache.Entries,
cache.Capacity,
cache.Conferences,
cache.Events,
cache.Searches,
cache.Redirects,
cache.Hits,
cache.Misses,
HitRate(cache.Hits, cache.Misses)),
new ProbeStatus(
probe.Entries,
probe.Capacity,
probe.Hits,
probe.Misses,
HitRate(probe.Hits, probe.Misses),
probe.ServedItems));
}
private SyncStatus SyncStatus()
{
var worker = _taskManager.ScheduledTasks
.FirstOrDefault(t => string.Equals(t.ScheduledTask.Key, ChaosflixSyncTask.TaskKey, StringComparison.Ordinal));
if (worker is null)
{
return new SyncStatus(false, null, null, null, null, null);
}
var last = worker.LastExecutionResult;
return new SyncStatus(
true,
worker.State.ToString(),
last?.StartTimeUtc,
last is null ? null : Math.Round((last.EndTimeUtc - last.StartTimeUtc).TotalSeconds, 1),
last?.Status.ToString(),
last?.ErrorMessage);
}
private static double? HitRate(long hits, long misses) =>
hits + misses == 0 ? null : Math.Round(100.0 * hits / (hits + misses), 1);
private static double Elapsed(long startedAt) =>
Math.Round(Stopwatch.GetElapsedTime(startedAt).TotalMilliseconds, 1);
///
/// Reduces whatever a reader pasted to the identifier the API takes: a bare guid, a
/// channel item id, or the last segment of a media.ccc.de url.
///
internal static string NormalizeTalkId(string? id)
{
var value = id?.Trim() ?? string.Empty;
if (value.Length == 0)
{
return string.Empty;
}
var query = value.IndexOfAny(['?', '#']);
if (query >= 0)
{
value = value[..query];
}
value = value.TrimEnd('/');
var slash = value.LastIndexOf('/');
if (slash >= 0)
{
value = value[(slash + 1)..];
}
var colon = value.LastIndexOf(':');
return colon >= 0 ? value[(colon + 1)..] : value;
}
}
/// The plugin's state, as the configuration page shows it.
/// Plugin version.
/// Endpoint requests go to.
/// Whether that is the public default rather than a configured mirror.
/// The scheduled sync task's last run.
/// What the CCC API cache holds.
/// What the probe cache holds.
public record PluginStatus(
string Version,
string ApiBaseUrl,
bool ApiBaseUrlIsDefault,
SyncStatus Sync,
CacheStatus ApiCache,
ProbeStatus ProbeCache);
/// The sync task's last run.
/// Whether the server knows the task at all.
/// Idle, running or cancelled.
/// When the last run started.
/// How long it took.
/// Completed, failed, aborted or cancelled.
/// The failure message, when there was one.
public record SyncStatus(
bool Registered,
string? State,
DateTime? LastRunUtc,
double? DurationSeconds,
string? LastResult,
string? Error);
/// What the CCC API cache holds.
/// Entries that would still be served.
/// Entries kept before the soonest-expiring ones are evicted.
/// Conference list and conference detail entries.
/// Talk detail entries.
/// Cached searches.
/// Resolved CDN mirrors.
/// Reads answered from the cache since the server started.
/// Reads that had to call the API.
/// Hits as a percentage, or null when nothing was read yet.
public record CacheStatus(
int Entries,
int Capacity,
int Conferences,
int Events,
int Searches,
int Redirects,
long Hits,
long Misses,
double? HitRatePercent);
/// What the probe cache holds.
/// Probed stream layouts still valid.
/// Entries kept before the oldest are evicted.
/// Playbacks answered from a cached probe.
/// Playbacks that had to run ffprobe.
/// Hits as a percentage, or null when nothing was probed yet.
/// Talks whose media sources a configuration change would invalidate.
public record ProbeStatus(
int Entries,
int Capacity,
long Hits,
long Misses,
double? HitRatePercent,
int ServedItems);
/// Outcome of the explicit endpoint check.
/// Whether the endpoint answered with a success status.
/// The url that was asked.
/// The HTTP status, when there was an answer.
/// Round-trip time.
/// Conferences the endpoint listed.
/// Why it failed, when it did.
public record ApiCheck(
bool Ok,
string Url,
int? StatusCode,
double ElapsedMs,
int? ConferenceCount,
string? Error);
/// Outcome of resolving one talk end to end.
/// Whether every stage passed.
/// The identifier that was resolved.
/// The talk's title, once the API answered.
/// The first stage that failed.
/// Every stage that was reached, in order.
public record TalkCheck(
bool Ok,
string TalkId,
string? Title,
string? FailedStage,
IReadOnlyList Stages);
/// One stage of the talk check.
/// Stage name.
/// Whether it passed.
/// What it found, or why it failed.
/// How long it took.
public record TalkCheckStage(string Name, bool Ok, string Detail, double ElapsedMs);