Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,354 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// HTTP client for the media.ccc.de public API with built-in caching.
|
||||
/// </summary>
|
||||
public class CccApiClient : IDisposable
|
||||
{
|
||||
/// <summary>Default public API endpoint; overridable for mirrors and tests.</summary>
|
||||
public const string DefaultBaseUrl = "https://api.media.ccc.de/public";
|
||||
|
||||
/// <summary>
|
||||
/// Default endpoint reporting which rooms are on air. A separate service from the
|
||||
/// archive API, hence its own base url.
|
||||
/// </summary>
|
||||
public const string DefaultStreamingBaseUrl = "https://streaming.media.ccc.de";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the <see cref="IHttpClientFactory"/> client used to resolve CDN redirects.
|
||||
/// It must be configured with <c>AllowAutoRedirect = false</c>.
|
||||
/// </summary>
|
||||
public const string RedirectClientName = "Chaosflix.CdnRedirect";
|
||||
|
||||
private const string ConferencesCacheKey = "conferences";
|
||||
|
||||
/// <summary>
|
||||
/// How long the conference list and conference details stay cached.
|
||||
/// <see cref="Channel.ChaosflixSyncTask"/> rewrites both on every run, so this only has to
|
||||
/// outlive <see cref="Channel.ChaosflixSyncTask.SyncInterval"/>; the extra hour of margin keeps
|
||||
/// the pre-warmed data usable when a run is late, skipped or still in progress.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan ConferenceCacheTtl = TimeSpan.FromHours(7);
|
||||
|
||||
/// <summary>Event detail with recordings — cache for 15 minutes.</summary>
|
||||
private static readonly TimeSpan EventTtl = TimeSpan.FromMinutes(15);
|
||||
|
||||
/// <summary>Search results — cache for 10 minutes.</summary>
|
||||
private static readonly TimeSpan SearchTtl = TimeSpan.FromMinutes(10);
|
||||
|
||||
/// <summary>
|
||||
/// Which rooms are live — cache for a minute. Everything else here describes an
|
||||
/// archive that changes by the day; this changes between two talks.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan LiveTtl = TimeSpan.FromMinutes(1);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan ReachabilityTimeout = TimeSpan.FromSeconds(10);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILogger<CccApiClient> _logger;
|
||||
private readonly MemoryCache _cache = new();
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="CccApiClient"/> class.
|
||||
/// </summary>
|
||||
public CccApiClient(IHttpClientFactory httpClientFactory, ILogger<CccApiClient> logger)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_httpClient = httpClientFactory.CreateClient(nameof(CccApiClient));
|
||||
_httpClient.DefaultRequestHeaders.Accept.Add(
|
||||
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all conferences (cached).
|
||||
/// </summary>
|
||||
public Task<List<CccConference>> GetConferencesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _cache.GetOrCreateAsync(ConferencesCacheKey, ConferenceCacheTtl, async ct =>
|
||||
{
|
||||
_logger.LogDebug("Fetching conferences from CCC API");
|
||||
var response = await _httpClient
|
||||
.GetFromJsonAsync<CccConferencesResponse>(Url("/conferences"), ct)
|
||||
.ConfigureAwait(false);
|
||||
return response?.Conferences ?? new List<CccConference>();
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single conference with its events (cached).
|
||||
/// </summary>
|
||||
public Task<CccConference?> 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<CccConference>(Url($"/conferences/{Uri.EscapeDataString(acronym)}"), ct)
|
||||
.ConfigureAwait(false);
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refetches the conference list and replaces the cached copy, leaving every other
|
||||
/// cache entry (events, CDN redirects) untouched.
|
||||
/// </summary>
|
||||
public Task<List<CccConference>> RefreshConferencesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_cache.Remove(ConferencesCacheKey);
|
||||
return GetConferencesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refetches a single conference and replaces the cached copy, leaving every other
|
||||
/// cache entry untouched.
|
||||
/// </summary>
|
||||
public Task<CccConference?> RefreshConferenceAsync(string acronym, CancellationToken cancellationToken)
|
||||
{
|
||||
_cache.Remove(ConferenceCacheKey(acronym));
|
||||
return GetConferenceAsync(acronym, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a single event with its recordings (cached).
|
||||
/// </summary>
|
||||
public Task<CccEvent?> 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<CccEvent>(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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public Task<List<CccLiveConference>> 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<List<CccLiveConference>>($"{StreamingBaseUrl}/streams/v2.json", ct)
|
||||
.ConfigureAwait(false);
|
||||
return conferences ?? new List<CccLiveConference>();
|
||||
}
|
||||
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<CccLiveConference>();
|
||||
}
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for events (cached).
|
||||
/// </summary>
|
||||
public Task<List<CccEvent>> 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<CccEventsResponse>(Url($"/events/search?q={encoded}"), ct)
|
||||
.ConfigureAwait(false);
|
||||
return response?.Events ?? new List<CccEvent>();
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint requests actually go to: the configured one, or the public default.
|
||||
/// </summary>
|
||||
public static string BaseUrl
|
||||
{
|
||||
get
|
||||
{
|
||||
var configured = Plugin.Instance?.Configuration.ApiBaseUrl;
|
||||
return string.IsNullOrWhiteSpace(configured) ? DefaultBaseUrl : configured.TrimEnd('/');
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the endpoint the live stream list is read from: the configured one, or the
|
||||
/// public default.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
internal async Task<ReachabilityResult> 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<CccConferencesResponse>(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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Counts what the cache currently holds, per kind of entry.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Outcome of <see cref="CheckReachabilityAsync"/>.</summary>
|
||||
internal sealed record ReachabilityResult(
|
||||
bool Ok,
|
||||
int? StatusCode,
|
||||
double ElapsedMs,
|
||||
int? ConferenceCount,
|
||||
string? Error,
|
||||
string Url);
|
||||
|
||||
/// <summary>What the API cache holds right now.</summary>
|
||||
internal sealed record CacheSnapshot(
|
||||
int Entries,
|
||||
int Conferences,
|
||||
int Events,
|
||||
int Searches,
|
||||
int Redirects,
|
||||
int Capacity,
|
||||
long Hits,
|
||||
long Misses);
|
||||
|
||||
/// <summary>
|
||||
/// Clears the entire cache.
|
||||
/// </summary>
|
||||
public void ClearCache() => _cache.Clear();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<string> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forgets the cached mirror for <paramref name="url"/> so that the next resolution
|
||||
/// asks the CDN again. Used when the cached mirror stopped serving the recording.
|
||||
/// </summary>
|
||||
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}";
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
[Authorize(Policy = ElevationPolicy)]
|
||||
public class ChaosflixStatusController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<ChaosflixStatusController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixStatusController"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixStatusController(
|
||||
CccApiClient apiClient,
|
||||
ChaosflixChannel channel,
|
||||
ITaskManager taskManager,
|
||||
ILogger<ChaosflixStatusController> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_channel = channel;
|
||||
_taskManager = taskManager;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the plugin's state without touching the network.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public ActionResult<PluginStatus> GetStatus() => BuildStatus();
|
||||
|
||||
/// <summary>
|
||||
/// Asks the configured endpoint whether it answers, and how fast.
|
||||
/// </summary>
|
||||
[HttpPost("CheckApi")]
|
||||
public async Task<ActionResult<ApiCheck>> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Empties every cache the plugin keeps, along the same path a configuration change
|
||||
/// takes, and returns the state afterwards.
|
||||
/// </summary>
|
||||
[HttpPost("ClearCaches")]
|
||||
public ActionResult<PluginStatus> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <param name="id">A talk guid, slug, channel item id or media.ccc.de url.</param>
|
||||
[HttpPost("CheckTalk")]
|
||||
public async Task<ActionResult<TalkCheck>> 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<TalkCheckStage>();
|
||||
|
||||
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<MediaStream>();
|
||||
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<TalkCheckStage> stages)
|
||||
{
|
||||
var failed = stages.FirstOrDefault(s => !s.Ok);
|
||||
return new TalkCheck(failed is null, guid, title, failed?.Name, stages);
|
||||
}
|
||||
|
||||
private async Task<TalkCheckStage> 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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The plugin's state, as the configuration page shows it.</summary>
|
||||
/// <param name="Version">Plugin version.</param>
|
||||
/// <param name="ApiBaseUrl">Endpoint requests go to.</param>
|
||||
/// <param name="ApiBaseUrlIsDefault">Whether that is the public default rather than a configured mirror.</param>
|
||||
/// <param name="Sync">The scheduled sync task's last run.</param>
|
||||
/// <param name="ApiCache">What the CCC API cache holds.</param>
|
||||
/// <param name="ProbeCache">What the probe cache holds.</param>
|
||||
public record PluginStatus(
|
||||
string Version,
|
||||
string ApiBaseUrl,
|
||||
bool ApiBaseUrlIsDefault,
|
||||
SyncStatus Sync,
|
||||
CacheStatus ApiCache,
|
||||
ProbeStatus ProbeCache);
|
||||
|
||||
/// <summary>The sync task's last run.</summary>
|
||||
/// <param name="Registered">Whether the server knows the task at all.</param>
|
||||
/// <param name="State">Idle, running or cancelled.</param>
|
||||
/// <param name="LastRunUtc">When the last run started.</param>
|
||||
/// <param name="DurationSeconds">How long it took.</param>
|
||||
/// <param name="LastResult">Completed, failed, aborted or cancelled.</param>
|
||||
/// <param name="Error">The failure message, when there was one.</param>
|
||||
public record SyncStatus(
|
||||
bool Registered,
|
||||
string? State,
|
||||
DateTime? LastRunUtc,
|
||||
double? DurationSeconds,
|
||||
string? LastResult,
|
||||
string? Error);
|
||||
|
||||
/// <summary>What the CCC API cache holds.</summary>
|
||||
/// <param name="Entries">Entries that would still be served.</param>
|
||||
/// <param name="Capacity">Entries kept before the soonest-expiring ones are evicted.</param>
|
||||
/// <param name="Conferences">Conference list and conference detail entries.</param>
|
||||
/// <param name="Events">Talk detail entries.</param>
|
||||
/// <param name="Searches">Cached searches.</param>
|
||||
/// <param name="Redirects">Resolved CDN mirrors.</param>
|
||||
/// <param name="Hits">Reads answered from the cache since the server started.</param>
|
||||
/// <param name="Misses">Reads that had to call the API.</param>
|
||||
/// <param name="HitRatePercent">Hits as a percentage, or null when nothing was read yet.</param>
|
||||
public record CacheStatus(
|
||||
int Entries,
|
||||
int Capacity,
|
||||
int Conferences,
|
||||
int Events,
|
||||
int Searches,
|
||||
int Redirects,
|
||||
long Hits,
|
||||
long Misses,
|
||||
double? HitRatePercent);
|
||||
|
||||
/// <summary>What the probe cache holds.</summary>
|
||||
/// <param name="Entries">Probed stream layouts still valid.</param>
|
||||
/// <param name="Capacity">Entries kept before the oldest are evicted.</param>
|
||||
/// <param name="Hits">Playbacks answered from a cached probe.</param>
|
||||
/// <param name="Misses">Playbacks that had to run ffprobe.</param>
|
||||
/// <param name="HitRatePercent">Hits as a percentage, or null when nothing was probed yet.</param>
|
||||
/// <param name="ServedItems">Talks whose media sources a configuration change would invalidate.</param>
|
||||
public record ProbeStatus(
|
||||
int Entries,
|
||||
int Capacity,
|
||||
long Hits,
|
||||
long Misses,
|
||||
double? HitRatePercent,
|
||||
int ServedItems);
|
||||
|
||||
/// <summary>Outcome of the explicit endpoint check.</summary>
|
||||
/// <param name="Ok">Whether the endpoint answered with a success status.</param>
|
||||
/// <param name="Url">The url that was asked.</param>
|
||||
/// <param name="StatusCode">The HTTP status, when there was an answer.</param>
|
||||
/// <param name="ElapsedMs">Round-trip time.</param>
|
||||
/// <param name="ConferenceCount">Conferences the endpoint listed.</param>
|
||||
/// <param name="Error">Why it failed, when it did.</param>
|
||||
public record ApiCheck(
|
||||
bool Ok,
|
||||
string Url,
|
||||
int? StatusCode,
|
||||
double ElapsedMs,
|
||||
int? ConferenceCount,
|
||||
string? Error);
|
||||
|
||||
/// <summary>Outcome of resolving one talk end to end.</summary>
|
||||
/// <param name="Ok">Whether every stage passed.</param>
|
||||
/// <param name="TalkId">The identifier that was resolved.</param>
|
||||
/// <param name="Title">The talk's title, once the API answered.</param>
|
||||
/// <param name="FailedStage">The first stage that failed.</param>
|
||||
/// <param name="Stages">Every stage that was reached, in order.</param>
|
||||
public record TalkCheck(
|
||||
bool Ok,
|
||||
string TalkId,
|
||||
string? Title,
|
||||
string? FailedStage,
|
||||
IReadOnlyList<TalkCheckStage> Stages);
|
||||
|
||||
/// <summary>One stage of the talk check.</summary>
|
||||
/// <param name="Name">Stage name.</param>
|
||||
/// <param name="Ok">Whether it passed.</param>
|
||||
/// <param name="Detail">What it found, or why it failed.</param>
|
||||
/// <param name="ElapsedMs">How long it took.</param>
|
||||
public record TalkCheckStage(string Name, bool Ok, string Detail, double ElapsedMs);
|
||||
@@ -0,0 +1,255 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
using Jellyfin.Plugin.Chaosflix.Configuration;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// API controller that acts as a transparent reverse proxy for CCC recordings.
|
||||
/// Proxies CDN content with full HTTP Range request support, enabling seeking.
|
||||
/// By serving content through a local endpoint, the Jellyfin server treats it
|
||||
/// as a non-remote source and can use DirectStream instead of forced transcoding.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class ChaosflixStreamController : ControllerBase
|
||||
{
|
||||
private readonly CccApiClient _apiClient;
|
||||
private readonly ILogger<ChaosflixStreamController> _logger;
|
||||
private static readonly HttpClient _proxyClient = new HttpClient { Timeout = TimeSpan.FromMinutes(60) };
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ChaosflixStreamController"/> class.
|
||||
/// </summary>
|
||||
public ChaosflixStreamController(CccApiClient apiClient, ILogger<ChaosflixStreamController> logger)
|
||||
{
|
||||
_apiClient = apiClient;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Proxies a CCC recording from the CDN with full Range request support.
|
||||
/// This enables seeking in both browser and Android clients.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second route is what subtitles use. Their signature and filename sit in
|
||||
/// the path so the url ends in <c>.srt</c> or <c>.vtt</c>: Jellyfin and its
|
||||
/// clients derive a subtitle's format from the extension of the url they are
|
||||
/// handed, and a query string would end up in <c>Path.GetExtension</c>.
|
||||
/// </remarks>
|
||||
[HttpGet("proxy/{eventGuid}")]
|
||||
[HttpHead("proxy/{eventGuid}")]
|
||||
[HttpGet("proxy/{eventGuid}/{signature}/{filename}")]
|
||||
[HttpHead("proxy/{eventGuid}/{signature}/{filename}")]
|
||||
public async Task ProxyStream(
|
||||
[FromRoute] string eventGuid,
|
||||
[FromRoute] string? signature = null,
|
||||
[FromRoute] string? filename = null,
|
||||
[FromQuery] string? recordingFolder = null,
|
||||
[FromQuery] string? language = null,
|
||||
[FromQuery(Name = ProxySignature.QueryParameter)] string? t = null)
|
||||
{
|
||||
// The endpoint is anonymous because the server's own ffmpeg fetches this
|
||||
// url; the signature is what keeps it from being a general purpose relay.
|
||||
if (!ProxySignature.Verify(eventGuid, recordingFolder, language, signature ?? t, filename))
|
||||
{
|
||||
_logger.LogWarning("Rejected unsigned proxy request for {EventGuid}", eventGuid);
|
||||
Response.StatusCode = 401;
|
||||
return;
|
||||
}
|
||||
|
||||
var cancellationToken = HttpContext.RequestAborted;
|
||||
|
||||
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
|
||||
if (cccEvent?.Recordings == null)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
CccRecording? recording;
|
||||
|
||||
if (filename != null)
|
||||
{
|
||||
// Subtitles share the folder "" across every language and format, so the
|
||||
// filename is the only thing that identifies one; it is signed like the rest.
|
||||
recording = cccEvent.Recordings
|
||||
.FirstOrDefault(r => r.Filename.Equals(filename, StringComparison.Ordinal));
|
||||
}
|
||||
else
|
||||
{
|
||||
var videoRecordings = cccEvent.Recordings
|
||||
.Where(r => r.MimeType.StartsWith("video/", StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (videoRecordings.Count == 0)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
recording = videoRecordings.FirstOrDefault(r =>
|
||||
(recordingFolder == null || r.Folder.Equals(recordingFolder, StringComparison.OrdinalIgnoreCase)) &&
|
||||
(language == null || r.Language.Equals(language, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
recording ??= videoRecordings
|
||||
.OrderByDescending(r => r.MimeType.Contains("mp4", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
|
||||
.ThenByDescending(r => r.HighQuality ? 1 : 0)
|
||||
.First();
|
||||
}
|
||||
|
||||
if (recording == null)
|
||||
{
|
||||
Response.StatusCode = 404;
|
||||
return;
|
||||
}
|
||||
|
||||
var originalUrl = recording.RecordingUrl;
|
||||
var resolvedUrl = await _apiClient.ResolveRedirectAsync(originalUrl, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
_logger.LogDebug("Proxying {EventGuid} from {Url}", eventGuid, resolvedUrl);
|
||||
|
||||
RangeHeaderValue? range = null;
|
||||
if (Request.Headers.TryGetValue("Range", out var rangeHeader)
|
||||
&& !RangeHeaderValue.TryParse(rangeHeader.ToString(), out range))
|
||||
{
|
||||
// RFC 9110: a Range header with invalid syntax is ignored, not rejected.
|
||||
_logger.LogDebug("Ignoring malformed Range header {Range}", rangeHeader.ToString());
|
||||
range = null;
|
||||
}
|
||||
|
||||
var method = HttpContext.Request.Method == "HEAD" ? HttpMethod.Head : HttpMethod.Get;
|
||||
|
||||
var upstreamResponse = await SendUpstreamAsync(method, resolvedUrl, range, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// A cached mirror can die long before its two-hour cache entry expires; drop it
|
||||
// and give the CDN one chance to hand out a mirror that still has the recording.
|
||||
if (upstreamResponse?.IsSuccessStatusCode != true
|
||||
&& !string.Equals(resolvedUrl, originalUrl, StringComparison.Ordinal))
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Mirror {Url} failed for {EventGuid} ({Status}), retrying via {Original}",
|
||||
resolvedUrl,
|
||||
eventGuid,
|
||||
upstreamResponse?.StatusCode.ToString() ?? "no response",
|
||||
originalUrl);
|
||||
|
||||
upstreamResponse?.Dispose();
|
||||
_apiClient.InvalidateRedirect(originalUrl);
|
||||
upstreamResponse = await SendUpstreamAsync(method, originalUrl, range, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (upstreamResponse == null)
|
||||
{
|
||||
Response.StatusCode = 502;
|
||||
return;
|
||||
}
|
||||
|
||||
using var finalResponse = upstreamResponse;
|
||||
|
||||
// Set response status (200 or 206)
|
||||
Response.StatusCode = (int)finalResponse.StatusCode;
|
||||
|
||||
if (!finalResponse.IsSuccessStatusCode)
|
||||
{
|
||||
// An error body is not media, so it gets neither Accept-Ranges nor a video type.
|
||||
// The unsatisfied-range of a 416 is the exception: RFC 9110 has clients read the
|
||||
// real size off it and ask again for a range that fits (#104).
|
||||
if (finalResponse.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable
|
||||
&& finalResponse.Content.Headers.ContentRange != null)
|
||||
{
|
||||
Response.Headers["Content-Range"] = finalResponse.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward relevant headers
|
||||
if (finalResponse.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
Response.ContentLength = finalResponse.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
Response.Headers["Accept-Ranges"] = "bytes";
|
||||
|
||||
if (filename != null)
|
||||
{
|
||||
// The subtitle mirrors hand out .srt as application/octet-stream, which
|
||||
// no client renders as a caption track; the API's own mime type is the
|
||||
// one that describes the bytes.
|
||||
Response.ContentType = recording.MimeType;
|
||||
}
|
||||
else if (finalResponse.Content.Headers.ContentType != null)
|
||||
{
|
||||
Response.ContentType = finalResponse.Content.Headers.ContentType.ToString();
|
||||
}
|
||||
else
|
||||
{
|
||||
Response.ContentType = recording.MimeType.Contains("mp4", StringComparison.OrdinalIgnoreCase)
|
||||
? "video/mp4" : "video/webm";
|
||||
}
|
||||
|
||||
if (finalResponse.Content.Headers.ContentRange != null)
|
||||
{
|
||||
Response.Headers["Content-Range"] = finalResponse.Content.Headers.ContentRange.ToString();
|
||||
}
|
||||
|
||||
// Stream body (skip for HEAD requests)
|
||||
if (HttpContext.Request.Method != "HEAD")
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var upstreamStream = await finalResponse.Content.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await upstreamStream.CopyToAsync(Response.Body, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// ffmpeg being killed at the end of a transcode, a player seeking or a
|
||||
// browser tab closing all hang up mid-file: that is how a proxied media
|
||||
// response normally ends. Letting it unwind would reach Jellyfin's
|
||||
// exception middleware with the response already on the wire, which can
|
||||
// only log a warning about it (#58).
|
||||
_logger.LogDebug("Client aborted the stream for {EventGuid}", eventGuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage?> SendUpstreamAsync(
|
||||
HttpMethod method,
|
||||
string url,
|
||||
RangeHeaderValue? range,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var request = new HttpRequestMessage(method, url);
|
||||
if (range != null)
|
||||
{
|
||||
request.Headers.Range = range;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return await _proxyClient
|
||||
.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Upstream request to {Url} failed", url);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Simple in-memory cache with TTL expiration, a capacity bound and stampede protection.
|
||||
/// </summary>
|
||||
public class MemoryCache
|
||||
{
|
||||
/// <summary>
|
||||
/// Default number of entries kept before the soonest-expiring ones are evicted.
|
||||
/// </summary>
|
||||
internal const int DefaultCapacity = 256;
|
||||
|
||||
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
|
||||
private readonly ConcurrentDictionary<string, Gate> _locks = new();
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly int _capacity;
|
||||
private long _hits;
|
||||
private long _misses;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="MemoryCache"/> class.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Maximum number of entries to keep.</param>
|
||||
/// <param name="timeProvider">Clock used for expiry, for tests.</param>
|
||||
public MemoryCache(int capacity = DefaultCapacity, TimeProvider? timeProvider = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(capacity, 1);
|
||||
_capacity = capacity;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
}
|
||||
|
||||
internal int Count => _cache.Count;
|
||||
|
||||
internal int LockCount => _locks.Count;
|
||||
|
||||
internal int Capacity => _capacity;
|
||||
|
||||
internal long Hits => Interlocked.Read(ref _hits);
|
||||
|
||||
internal long Misses => Interlocked.Read(ref _misses);
|
||||
|
||||
/// <summary>
|
||||
/// Counts the entries that would still be served. Expired entries are only dropped
|
||||
/// when something is written, so <see cref="Count"/> can outrun what is usable.
|
||||
/// </summary>
|
||||
internal int CountLive(Func<string, bool>? predicate = null)
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
return _cache.Count(entry =>
|
||||
entry.Value.ExpiresAt > now && (predicate is null || predicate(entry.Key)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or creates a cached value.
|
||||
/// </summary>
|
||||
public async Task<T> GetOrCreateAsync<T>(string key, TimeSpan ttl, Func<CancellationToken, Task<T>> factory, CancellationToken cancellationToken)
|
||||
{
|
||||
if (TryGet<T>(key, out var hit))
|
||||
{
|
||||
Interlocked.Increment(ref _hits);
|
||||
return hit;
|
||||
}
|
||||
|
||||
var gate = RentGate(key);
|
||||
try
|
||||
{
|
||||
await gate.Semaphore.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
if (TryGet<T>(key, out hit))
|
||||
{
|
||||
Interlocked.Increment(ref _hits);
|
||||
return hit;
|
||||
}
|
||||
|
||||
Interlocked.Increment(ref _misses);
|
||||
var value = await factory(cancellationToken).ConfigureAwait(false);
|
||||
_cache[key] = new CacheEntry(value!, _timeProvider.GetUtcNow().Add(ttl));
|
||||
Evict();
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
gate.Semaphore.Release();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ReturnGate(key, gate);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates a single cached entry.
|
||||
/// </summary>
|
||||
public void Remove(string key) => _cache.TryRemove(key, out _);
|
||||
|
||||
/// <summary>
|
||||
/// Invalidates all cached entries and releases the per-key locks that are idle.
|
||||
/// </summary>
|
||||
public void Clear()
|
||||
{
|
||||
_cache.Clear();
|
||||
foreach (var pair in _locks)
|
||||
{
|
||||
TryDropGate(pair.Key, pair.Value);
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryGet<T>(string key, out T value)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > _timeProvider.GetUtcNow())
|
||||
{
|
||||
value = (T)entry.Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
value = default!;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void Evict()
|
||||
{
|
||||
var now = _timeProvider.GetUtcNow();
|
||||
foreach (var entry in _cache)
|
||||
{
|
||||
if (entry.Value.ExpiresAt <= now)
|
||||
{
|
||||
_cache.TryRemove(entry.Key, out _);
|
||||
}
|
||||
}
|
||||
|
||||
while (_cache.Count > _capacity)
|
||||
{
|
||||
var oldest = _cache.OrderBy(e => e.Value.ExpiresAt).Select(e => e.Key).FirstOrDefault();
|
||||
if (oldest is null || !_cache.TryRemove(oldest, out _))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A gate lives only as long as someone is using it, so the lock dictionary stays
|
||||
// bounded by the number of in-flight calls instead of the number of keys ever seen.
|
||||
private Gate RentGate(string key)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var gate = _locks.GetOrAdd(key, _ => new Gate());
|
||||
lock (gate)
|
||||
{
|
||||
if (!gate.Dropped)
|
||||
{
|
||||
gate.Users++;
|
||||
return gate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ReturnGate(string key, Gate gate)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
gate.Users--;
|
||||
DropIfIdle(key, gate);
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDropGate(string key, Gate gate)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
DropIfIdle(key, gate);
|
||||
}
|
||||
}
|
||||
|
||||
private void DropIfIdle(string key, Gate gate)
|
||||
{
|
||||
if (gate.Users > 0 || gate.Dropped)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
gate.Dropped = true;
|
||||
_locks.TryRemove(new KeyValuePair<string, Gate>(key, gate));
|
||||
gate.Semaphore.Dispose();
|
||||
}
|
||||
|
||||
private sealed record CacheEntry(object Value, DateTimeOffset ExpiresAt);
|
||||
|
||||
private sealed class Gate
|
||||
{
|
||||
public SemaphoreSlim Semaphore { get; } = new(1, 1);
|
||||
|
||||
public int Users { get; set; }
|
||||
|
||||
public bool Dropped { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// CCC API conference (e.g. 38C3, Camp 2019).
|
||||
/// </summary>
|
||||
public class CccConference
|
||||
{
|
||||
/// <summary>Gets or sets the acronym (e.g. "38c3").</summary>
|
||||
[JsonPropertyName("acronym")]
|
||||
public string Acronym { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the title.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the slug.</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the logo URL.</summary>
|
||||
[JsonPropertyName("logo_url")]
|
||||
public string? LogoUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the aspect ratio.</summary>
|
||||
[JsonPropertyName("aspect_ratio")]
|
||||
public string? AspectRatio { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the API URL.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the last released event date.</summary>
|
||||
[JsonPropertyName("event_last_released_at")]
|
||||
public DateTimeOffset? EventLastReleasedAt { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the events (only present in detail response).</summary>
|
||||
[JsonPropertyName("events")]
|
||||
public List<CccEvent>? Events { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for conference list response.
|
||||
/// </summary>
|
||||
public class CccConferencesResponse
|
||||
{
|
||||
/// <summary>Gets or sets the conferences.</summary>
|
||||
[JsonPropertyName("conferences")]
|
||||
public List<CccConference> Conferences { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// CCC API event (a single talk/recording).
|
||||
/// </summary>
|
||||
public class CccEvent
|
||||
{
|
||||
/// <summary>Gets or sets the GUID.</summary>
|
||||
[JsonPropertyName("guid")]
|
||||
public string Guid { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the title.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the subtitle.</summary>
|
||||
[JsonPropertyName("subtitle")]
|
||||
public string? Subtitle { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the slug.</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the description.</summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the original language.</summary>
|
||||
[JsonPropertyName("original_language")]
|
||||
public string? OriginalLanguage { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the speakers.</summary>
|
||||
[JsonPropertyName("persons")]
|
||||
public List<string> Persons { get; set; } = new();
|
||||
|
||||
/// <summary>Gets or sets the tags.</summary>
|
||||
[JsonPropertyName("tags")]
|
||||
public List<string> Tags { get; set; } = new();
|
||||
|
||||
/// <summary>Gets or sets the view count.</summary>
|
||||
[JsonPropertyName("view_count")]
|
||||
public int ViewCount { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the event date.</summary>
|
||||
[JsonPropertyName("date")]
|
||||
public DateTimeOffset? Date { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the release date.</summary>
|
||||
[JsonPropertyName("release_date")]
|
||||
public DateTimeOffset? ReleaseDate { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the duration in seconds.</summary>
|
||||
[JsonPropertyName("duration")]
|
||||
public int Duration { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the thumbnail URL.</summary>
|
||||
[JsonPropertyName("thumb_url")]
|
||||
public string? ThumbUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the poster URL.</summary>
|
||||
[JsonPropertyName("poster_url")]
|
||||
public string? PosterUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the frontend link.</summary>
|
||||
[JsonPropertyName("frontend_link")]
|
||||
public string? FrontendLink { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the API URL.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the conference title.</summary>
|
||||
[JsonPropertyName("conference_title")]
|
||||
public string? ConferenceTitle { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the conference API URL.</summary>
|
||||
[JsonPropertyName("conference_url")]
|
||||
public string? ConferenceUrl { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the recordings (only in detail response).</summary>
|
||||
[JsonPropertyName("recordings")]
|
||||
public List<CccRecording>? Recordings { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the related events.</summary>
|
||||
[JsonPropertyName("related")]
|
||||
public List<CccRelatedEvent>? Related { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wrapper for event search response.
|
||||
/// </summary>
|
||||
public class CccEventsResponse
|
||||
{
|
||||
/// <summary>Gets or sets the events.</summary>
|
||||
[JsonPropertyName("events")]
|
||||
public List<CccEvent> Events { get; set; } = new();
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// A conference as streaming.media.ccc.de describes it while it is running. The shape
|
||||
/// follows the generator at voc/streaming-website (view/streams-json-v2.php); outside a
|
||||
/// congress the endpoint answers with an empty list.
|
||||
/// </summary>
|
||||
public class CccLiveConference
|
||||
{
|
||||
/// <summary>Gets or sets the conference title.</summary>
|
||||
[JsonPropertyName("conference")]
|
||||
public string Conference { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the acronym, e.g. "39c3".</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether anything is on air right now. The site sets
|
||||
/// it from the rooms' schedules, which is what decides whether the channel offers a
|
||||
/// live folder at all.
|
||||
/// </summary>
|
||||
[JsonPropertyName("isCurrentlyStreaming")]
|
||||
public bool IsCurrentlyStreaming { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the room groups.</summary>
|
||||
[JsonPropertyName("groups")]
|
||||
public List<CccLiveGroup> Groups { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>A named set of rooms, e.g. "Live" or "Selfhosted".</summary>
|
||||
public class CccLiveGroup
|
||||
{
|
||||
/// <summary>Gets or sets the group name.</summary>
|
||||
[JsonPropertyName("group")]
|
||||
public string Group { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the rooms.</summary>
|
||||
[JsonPropertyName("rooms")]
|
||||
public List<CccLiveRoom> Rooms { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One room with its stream variants and what is on in it.</summary>
|
||||
public class CccLiveRoom
|
||||
{
|
||||
/// <summary>Gets or sets the room slug, e.g. "halla".</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the display name, e.g. "Adams".</summary>
|
||||
[JsonPropertyName("display")]
|
||||
public string Display { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the thumbnail url.</summary>
|
||||
[JsonPropertyName("thumb")]
|
||||
public string? Thumb { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the room's page on the streaming site.</summary>
|
||||
[JsonPropertyName("link")]
|
||||
public string? Link { get; set; }
|
||||
|
||||
/// <summary>Gets or sets what is running now and next.</summary>
|
||||
[JsonPropertyName("talks")]
|
||||
public CccLiveTalks? Talks { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the stream variants.</summary>
|
||||
[JsonPropertyName("streams")]
|
||||
public List<CccLiveStreamVariant> Streams { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>The talk on air and the one after it; both are null between talks.</summary>
|
||||
public class CccLiveTalks
|
||||
{
|
||||
/// <summary>Gets or sets the talk currently on air.</summary>
|
||||
[JsonPropertyName("current")]
|
||||
public CccLiveTalk? Current { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the next talk.</summary>
|
||||
[JsonPropertyName("next")]
|
||||
public CccLiveTalk? Next { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A scheduled talk, as the streaming site reports it.</summary>
|
||||
public class CccLiveTalk
|
||||
{
|
||||
/// <summary>Gets or sets the title.</summary>
|
||||
[JsonPropertyName("title")]
|
||||
public string? Title { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the speakers, already joined by the site.</summary>
|
||||
[JsonPropertyName("speaker")]
|
||||
public string? Speaker { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One way to watch a room: a resolution, a language and a player type. "native" is the
|
||||
/// floor language, "translated" an interpreter's channel.
|
||||
/// </summary>
|
||||
public class CccLiveStreamVariant
|
||||
{
|
||||
/// <summary>Gets or sets the variant slug, e.g. "hd-native".</summary>
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the human readable description.</summary>
|
||||
[JsonPropertyName("display")]
|
||||
public string Display { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the player type: video, slides, audio, music or dash.</summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string Type { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether this is an interpreter's channel.</summary>
|
||||
[JsonPropertyName("isTranslated")]
|
||||
public bool IsTranslated { get; set; }
|
||||
|
||||
/// <summary>Gets or sets width and height, absent for audio.</summary>
|
||||
[JsonPropertyName("videoSize")]
|
||||
public List<int>? VideoSize { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the urls by protocol ("hls", "webm", "dash", "mp3", "opus").</summary>
|
||||
[JsonPropertyName("urls")]
|
||||
public Dictionary<string, CccLiveStreamUrl> Urls { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One protocol's url for a stream variant.</summary>
|
||||
public class CccLiveStreamUrl
|
||||
{
|
||||
/// <summary>Gets or sets the description the site shows for it.</summary>
|
||||
[JsonPropertyName("display")]
|
||||
public string? Display { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the technical description, e.g. "hls".</summary>
|
||||
[JsonPropertyName("tech")]
|
||||
public string? Tech { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the stream url.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
using System;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api.Models;
|
||||
|
||||
/// <summary>
|
||||
/// CCC API recording (a specific video/audio file for an event).
|
||||
/// </summary>
|
||||
public class CccRecording
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the file size in MB. Null on subtitle recordings, which the
|
||||
/// API publishes without size, length or dimensions.
|
||||
/// </summary>
|
||||
[JsonPropertyName("size")]
|
||||
public int? Size { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the length in seconds; null on subtitle recordings.</summary>
|
||||
[JsonPropertyName("length")]
|
||||
public int? Length { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the MIME type.</summary>
|
||||
[JsonPropertyName("mime_type")]
|
||||
public string MimeType { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the language.</summary>
|
||||
[JsonPropertyName("language")]
|
||||
public string Language { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the filename.</summary>
|
||||
[JsonPropertyName("filename")]
|
||||
public string Filename { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the state.</summary>
|
||||
[JsonPropertyName("state")]
|
||||
public string State { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the folder (e.g. "h264-hd", "webm-sd").</summary>
|
||||
[JsonPropertyName("folder")]
|
||||
public string Folder { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets whether this is the high quality version.</summary>
|
||||
[JsonPropertyName("high_quality")]
|
||||
public bool HighQuality { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the width; null on subtitle recordings.</summary>
|
||||
[JsonPropertyName("width")]
|
||||
public int? Width { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the height; null on subtitle recordings.</summary>
|
||||
[JsonPropertyName("height")]
|
||||
public int? Height { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the direct recording URL.</summary>
|
||||
[JsonPropertyName("recording_url")]
|
||||
public string RecordingUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the API URL.</summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the updated timestamp.</summary>
|
||||
[JsonPropertyName("updated_at")]
|
||||
public DateTimeOffset? UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Related event reference.
|
||||
/// </summary>
|
||||
public class CccRelatedEvent
|
||||
{
|
||||
/// <summary>Gets or sets the related event GUID.</summary>
|
||||
[JsonPropertyName("event_guid")]
|
||||
public string EventGuid { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the relationship weight.</summary>
|
||||
[JsonPropertyName("weight")]
|
||||
public int Weight { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Signs the proxy URLs the channel hands out, so the endpoint only serves
|
||||
/// recordings this plugin selected. Without a signature anyone who can reach
|
||||
/// the server could stream arbitrary CCC recordings through it.
|
||||
/// </summary>
|
||||
public static class ProxySignature
|
||||
{
|
||||
/// <summary>Query parameter carrying the signature.</summary>
|
||||
public const string QueryParameter = "t";
|
||||
|
||||
/// <summary>
|
||||
/// Secret for this server process. It is deliberately not persisted: media
|
||||
/// sources are handed out per playback, so a restart simply means the next
|
||||
/// request gets a freshly signed url, and nothing has to be stored or
|
||||
/// migrated.
|
||||
/// </summary>
|
||||
public static string Secret { get; } = Guid.NewGuid().ToString("N");
|
||||
|
||||
/// <summary>
|
||||
/// Signs one recording. The signature covers exactly the parameters the
|
||||
/// controller acts on, so none of them can be swapped afterwards.
|
||||
/// </summary>
|
||||
public static string Create(string eventGuid, string? recordingFolder, string? language, string? filename = null)
|
||||
{
|
||||
var payload = string.Create(
|
||||
CultureInfo.InvariantCulture,
|
||||
$"{eventGuid}\n{recordingFolder ?? string.Empty}\n{language ?? string.Empty}\n{filename ?? string.Empty}");
|
||||
|
||||
var hash = HMACSHA256.HashData(Encoding.UTF8.GetBytes(Secret), Encoding.UTF8.GetBytes(payload));
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a signature in constant time.
|
||||
/// </summary>
|
||||
public static bool Verify(string eventGuid, string? recordingFolder, string? language, string? signature, string? filename = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(signature))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var expected = Create(eventGuid, recordingFolder, language, filename);
|
||||
return CryptographicOperations.FixedTimeEquals(
|
||||
Encoding.UTF8.GetBytes(expected),
|
||||
Encoding.UTF8.GetBytes(signature));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace Jellyfin.Plugin.Chaosflix.Api;
|
||||
|
||||
/// <summary>
|
||||
/// Reads back a proxy url the channel handed out, so a diagnostic can check it and show
|
||||
/// it without handing the reader a url they could stream through.
|
||||
/// </summary>
|
||||
internal static class ProxyUrl
|
||||
{
|
||||
private const string FolderParameter = "recordingFolder";
|
||||
private const string LanguageParameter = "language";
|
||||
|
||||
/// <summary>
|
||||
/// Renders the url without its signature. The result is rebuilt from the parts that
|
||||
/// were understood rather than edited in place, so nothing of the signature can
|
||||
/// survive a url shaped differently than expected.
|
||||
/// </summary>
|
||||
internal static string Redact(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return "<unparseable proxy url>";
|
||||
}
|
||||
|
||||
var query = Parse(uri);
|
||||
var parts = new List<string>();
|
||||
if (query.TryGetValue(FolderParameter, out var folder))
|
||||
{
|
||||
parts.Add($"{FolderParameter}={folder}");
|
||||
}
|
||||
|
||||
if (query.TryGetValue(LanguageParameter, out var language))
|
||||
{
|
||||
parts.Add($"{LanguageParameter}={language}");
|
||||
}
|
||||
|
||||
parts.Add($"{ProxySignature.QueryParameter}=<redacted>");
|
||||
return $"{uri.GetLeftPart(UriPartial.Path)}?{string.Join('&', parts)}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Whether the url carries a signature the proxy endpoint would accept for exactly
|
||||
/// the recording the url names.
|
||||
/// </summary>
|
||||
internal static bool IsSignedCorrectly(string url)
|
||||
{
|
||||
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var guid = uri.Segments.LastOrDefault()?.TrimEnd('/');
|
||||
if (string.IsNullOrEmpty(guid))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var query = Parse(uri);
|
||||
query.TryGetValue(ProxySignature.QueryParameter, out var signature);
|
||||
return ProxySignature.Verify(
|
||||
Uri.UnescapeDataString(guid),
|
||||
query.GetValueOrDefault(FolderParameter),
|
||||
query.GetValueOrDefault(LanguageParameter),
|
||||
signature);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> Parse(Uri uri)
|
||||
{
|
||||
var query = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var separator = pair.IndexOf('=', StringComparison.Ordinal);
|
||||
if (separator > 0)
|
||||
{
|
||||
query[pair[..separator]] = Uri.UnescapeDataString(pair[(separator + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user