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

- 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:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

@@ -0,0 +1,97 @@
name: Android e2e
# Never on push: booting an emulator and installing the SDK takes far longer
# than the rest of the test suite. Nightly plus on demand.
on:
schedule:
- cron: "17 3 * * *"
workflow_dispatch:
inputs:
jellyfin_android_version:
description: "jellyfin-android release tag to test against"
required: false
default: "v2.7.3"
concurrency:
group: android-e2e-${{ github.ref }}
cancel-in-progress: true
jobs:
android:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: "17"
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
# The emulator needs hardware acceleration; without this the device node
# is not writable for the runner user.
- name: Enable KVM
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' \
| sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Start the Jellyfin stack
env:
JELLYFIN_PORT: "8098"
COMPOSE_PROJECT_NAME: chaosflix-e2e-android
E2E_ARTIFACTS_DIR: ${{ github.workspace }}/e2e/android/.artifacts/stack
E2E_FIXTURE_SECONDS: "180"
run: |
e2e/scripts/build-artifacts.sh
docker compose -f e2e/docker-compose.yml up -d --wait
- name: Run the Android e2e suite
uses: reactivecircus/android-emulator-runner@v2
env:
# The emulator is provided by the action, and the stack is already up.
ANDROID_E2E_USE_RUNNING_EMULATOR: "1"
E2E_REUSE_STACK: "1"
JELLYFIN_PORT: "8098"
COMPOSE_PROJECT_NAME: chaosflix-e2e-android
JELLYFIN_ANDROID_VERSION: ${{ inputs.jellyfin_android_version || 'v2.7.3' }}
with:
api-level: 33
target: google_apis
arch: x86_64
profile: pixel_5
emulator-options: -no-window -no-boot-anim -no-snapshot -gpu swiftshader_indirect -no-audio -camera-back none
disable-animations: true
script: e2e/android/run.sh
- name: Collect artifacts
if: always()
uses: actions/upload-artifact@v4
with:
name: android-e2e-artifacts
path: |
e2e/android/.artifacts/*.xml
e2e/android/.artifacts/*.log
e2e/android/.artifacts/*.png
e2e/android/.artifacts/*.txt
# .artifacts is a hidden directory, and upload-artifact skips hidden
# files by default — without this the step matched nothing and every
# red nightly was undiagnosable (#123).
include-hidden-files: true
if-no-files-found: warn
- name: Stop the Jellyfin stack
if: always()
env:
COMPOSE_PROJECT_NAME: chaosflix-e2e-android
E2E_ARTIFACTS_DIR: ${{ github.workspace }}/e2e/android/.artifacts/stack
run: docker compose -f e2e/docker-compose.yml down -v

View File

@@ -0,0 +1,117 @@
name: CI
on:
pull_request:
push:
branches: [main]
# Nightly the suite also runs against the newest Jellyfin, so a break there is
# found within a day instead of by hand (#82). It is not on every PR: the
# pinned version is what targetAbi promises, and a second leg would double the
# runner minutes of every push.
schedule:
- cron: "11 2 * * *"
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
DOTNET_NOLOGO: "1"
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
# The e2e stack binds a host port and names its containers after the compose
# project; both have to be unique so a second run cannot collide with this one.
JELLYFIN_PORT: "8124"
jobs:
tests:
name: tests (jellyfin ${{ matrix.jellyfin.tag }})
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
fail-fast: false
matrix:
# && binds tighter than ||, so the scheduled check has to be one
# parenthesised term: without it the expression yields a boolean and
# fromJSON fails.
# Each leg carries a slug as well as the tag: a compose project name may
# not contain dots, and "12.1" does.
jellyfin: ${{ fromJSON((github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && '[{"tag":"12.1","slug":"pinned"},{"tag":"latest","slug":"latest"}]' || '[{"tag":"12.1","slug":"pinned"}]') }}
env:
JELLYFIN_TAG: ${{ matrix.jellyfin.tag }}
COMPOSE_PROJECT_NAME: chaosflix-e2e-ci-${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.jellyfin.slug }}
steps:
- uses: actions/checkout@v4
# run-tests.sh falls back to the SDK Docker image only when no dotnet is
# on PATH; installing the SDK here keeps the build out of a container.
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: npm
cache-dependency-path: e2e/package-lock.json
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Build.props') }}
restore-keys: nuget-${{ runner.os }}-
- name: Resolve the Playwright version
id: playwright
working-directory: e2e
run: |
version=$(node -p "require('./package-lock.json').packages['node_modules/@playwright/test'].version")
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@v4
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.playwright.outputs.version }}
- name: Install the Playwright browser
working-directory: e2e
run: |
npm ci
if [[ "${{ steps.playwright-cache.outputs.cache-hit }}" == "true" ]]; then
npx playwright install-deps chromium
else
npx playwright install --with-deps chromium
fi
# Shell tests, Release build, xUnit suite and the Playwright e2e stack,
# against the Jellyfin of this matrix leg (JELLYFIN_TAG).
# ANDROID_E2E stays unset: that suite is nightly (android-e2e.yml).
# ffmpeg is deliberately not installed: the runner has none, so the
# fixtures are built through build-artifacts.sh's container fallback,
# which stayed broken for as long as nothing exercised it.
- name: Run the test suite
run: ./run-tests.sh
- name: Upload the Playwright report
if: failure()
uses: actions/upload-artifact@v4
with:
# Two legs upload on a nightly run; identical names collide.
name: playwright-report-${{ matrix.jellyfin.slug }}
path: |
e2e/playwright-report
e2e/test-results
if-no-files-found: ignore
retention-days: 14
- name: Tear down the e2e stack
if: always()
working-directory: e2e
run: docker compose down -v --remove-orphans || true

View File

@@ -0,0 +1,49 @@
name: CCC API contract
# Never on push or pull_request: these tests query api.media.ccc.de, so a third-party
# outage would turn every PR red and every push would add load to someone else's free
# public service. Nightly instead — an upstream field rename is then found within a day
# (#18), and one run costs the API well under a dozen requests.
on:
schedule:
- cron: "43 4 * * *"
workflow_dispatch:
concurrency:
group: contract-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: read
env:
DOTNET_NOLOGO: "1"
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
jobs:
contract:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Build.props') }}
restore-keys: nuget-${{ runner.os }}-
# Without CCC_CONTRACT the [ContractFact] tests skip, which is what keeps them out of
# run-tests.sh; the Category filter keeps this job from re-running the offline suite.
- name: Run the contract tests against the real API
env:
CCC_CONTRACT: "1"
run: |
dotnet test Jellyfin.Plugin.Chaosflix.Tests/Jellyfin.Plugin.Chaosflix.Tests.csproj \
-c Release --filter "Category=Contract" --logger "console;verbosity=detailed"

View File

@@ -0,0 +1,49 @@
name: Install from manifest
# Tests the published release, not the checked-out code: a fresh Jellyfin adds
# the repository manifest, downloads the asset, verifies its checksum and loads
# the plugin. It therefore cannot run on a pull request — it runs after a
# release, and nightly to catch a new Jellyfin breaking an existing release.
on:
workflow_run:
workflows: ["Release"]
types: [completed]
schedule:
- cron: "43 4 * * *"
workflow_dispatch:
inputs:
jellyfin_tag:
description: "Jellyfin image tag to install into"
required: false
default: "latest"
manifest_url:
description: "Repository manifest to install from"
required: false
concurrency:
group: install-e2e
cancel-in-progress: false
jobs:
install:
# A failed release has nothing worth installing.
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4
# The manifest is fetched over the network, so the run tests whatever is
# published on the default branch right now — the same thing a user gets.
- name: Install the published plugin
env:
JELLYFIN_TAG: ${{ inputs.jellyfin_tag || 'latest' }}
MANIFEST_URL: ${{ inputs.manifest_url || '' }}
INSTALL_PORT: "8099"
run: |
if [[ -n "${MANIFEST_URL}" ]]; then
./e2e/install/install-from-manifest.sh
else
MANIFEST_URL="https://raw.githubusercontent.com/${GITHUB_REPOSITORY}/${{ github.event.repository.default_branch }}/manifest.json" \
./e2e/install/install-from-manifest.sh
fi

View File

@@ -0,0 +1,150 @@
name: Release
# release.sh commits a manifest.json whose sourceUrl points at the release
# asset, so the asset has to exist the moment the tag is pushed — otherwise
# every server that has the repository added downloads a 404 (see #19).
# This job owns the artifact: release.sh no longer builds one, because a ZIP
# built on the tagging machine is never byte-identical to the published one and
# its checksum described bytes nobody could download (#49).
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
DOTNET_NOLOGO: "1"
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
jobs:
release:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
# The annotated tag's own message is the fallback for the release notes,
# and it only arrives with the tag object itself.
- uses: actions/checkout@v4
with:
fetch-depth: 0
- uses: actions/setup-dotnet@v4
with:
dotnet-version: "10.0.x"
- name: Cache NuGet packages
uses: actions/cache@v4
with:
path: ~/.nuget/packages
key: nuget-${{ runner.os }}-${{ hashFiles('**/*.csproj', 'Directory.Build.props') }}
restore-keys: nuget-${{ runner.os }}-
- name: Check the tag against meta.json
run: |
meta=$(python3 -c "import json;print(json.load(open('Jellyfin.Plugin.Chaosflix/meta.json'))['version'])")
tagged="${GITHUB_REF_NAME#v}"
if [[ "${meta%.*}" != "$tagged" ]]; then
echo "::warning::tag ${GITHUB_REF_NAME} does not match meta.json version ${meta}"
fi
# Same output as release.sh: publish to ./artifacts, drop meta.json next
# to the DLL, and zip exactly those two files flat.
- name: Build the plugin
run: |
dotnet publish Jellyfin.Plugin.Chaosflix/Jellyfin.Plugin.Chaosflix.csproj -c Release -o ./artifacts
cp Jellyfin.Plugin.Chaosflix/meta.json artifacts/
- name: Create the ZIP
id: zip
run: |
zip_name="chaosflix-jellyfin-${GITHUB_REF_NAME}.zip"
(cd artifacts && zip -j "../${zip_name}" Jellyfin.Plugin.Chaosflix.dll meta.json)
md5=$(md5sum "${zip_name}" | cut -d' ' -f1)
echo "name=${zip_name}" >> "$GITHUB_OUTPUT"
echo "md5=${md5}" >> "$GITHUB_OUTPUT"
{
echo "### ${GITHUB_REF_NAME}"
echo
echo "- asset: \`${zip_name}\`"
echo "- md5: \`${md5}\` — recorded as \`checksum\` in manifest.json"
} >> "$GITHUB_STEP_SUMMARY"
# release.sh writes release-notes-v<version>.md but .gitignore keeps it
# out of the commit, so in practice the tag message is what is used.
- name: Collect the release notes
id: notes
run: |
notes_file="release-notes-${GITHUB_REF_NAME}.md"
if [[ -s "${notes_file}" ]]; then
cp "${notes_file}" notes.md
else
git tag -l --format='%(contents)' "${GITHUB_REF_NAME}" > notes.md
fi
if [[ ! -s notes.md ]]; then
echo "Release ${GITHUB_REF_NAME}" > notes.md
fi
# Re-runnable on purpose: a failure in a later step used to be
# unrecoverable, because re-running the job started here and gh refused an
# existing release, leaving the checksum unrecorded (#100).
- name: Create or update the GitHub release
env:
GH_TOKEN: ${{ github.token }}
ASSET: ${{ steps.zip.outputs.name }}
run: |
if gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then
echo "release ${GITHUB_REF_NAME} exists; uploading the asset again"
gh release upload "${GITHUB_REF_NAME}" "${ASSET}" --clobber
else
gh release create "${GITHUB_REF_NAME}" "${ASSET}" \
--title "${GITHUB_REF_NAME}" \
--notes-file notes.md
fi
# The checksum is taken from the asset as it was downloaded back from the
# release, and it lands on the default branch rather than on the tag: a
# tag is immutable, and what servers read is manifest.json on that branch.
- name: Record the checksum in manifest.json
env:
GH_TOKEN: ${{ github.token }}
ASSET: ${{ steps.zip.outputs.name }}
TAG: ${{ github.ref_name }}
BRANCH: ${{ github.event.repository.default_branch }}
run: |
git fetch origin "${BRANCH}"
git checkout "${BRANCH}"
gh release download "${TAG}" --pattern "${ASSET}" --dir published --clobber
MD5=$(md5sum "published/${ASSET}" | cut -d' ' -f1) && export MD5
rm -rf published
python3 - <<'PYTHON'
import json, os, sys
with open("manifest.json") as f:
data = json.load(f)
wanted = os.environ["TAG"].lstrip("v")
for entry in data[0]["versions"]:
if entry["version"] == wanted or entry["version"].startswith(wanted + "."):
entry["checksum"] = os.environ["MD5"]
break
else:
sys.exit(f"manifest.json has no entry for {wanted}")
with open("manifest.json", "w") as f:
json.dump(data, f, indent=2)
f.write("\n")
PYTHON
if git diff --quiet manifest.json; then
echo "manifest.json already carries this checksum"
exit 0
fi
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add manifest.json
git commit -m "release: record the ${TAG} checksum [skip ci]"
git push origin "HEAD:${BRANCH}"

View File

@@ -0,0 +1,7 @@
<Project>
<PropertyGroup>
<Version>0.4.0.0</Version>
<AssemblyVersion>0.4.0.0</AssemblyVersion>
<FileVersion>0.4.0.0</FileVersion>
</PropertyGroup>
</Project>

View File

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

View File

@@ -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);

View File

@@ -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;
}
}
}

View File

@@ -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; }
}
}

View File

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

View File

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

View File

@@ -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;
}

View File

@@ -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; }
}

View File

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,124 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Chaosflix.Api;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Chaosflix.Channel;
/// <summary>
/// Turns the weighted recommendations media.ccc.de publishes for a talk into the
/// "More like this" row every Jellyfin client already shows.
///
/// The plugin has always fetched them and could already list them behind a folder id
/// of <c>related-&lt;guid&gt;</c>, but nothing ever handed out such a folder, so the
/// feature was unreachable (#72). Answering here instead of emitting folders is what
/// keeps it from making the duplicate problem worse: a channel item is stored under
/// exactly one parent, so every folder of related talks would add another library
/// item for a talk that already exists (#54, #15). This adds none.
/// </summary>
public class ChaosflixSimilarItems : ILocalSimilarItemsProvider
{
/// <summary>How many related talks are offered when the caller names no limit.</summary>
internal const int DefaultLimit = 12;
private readonly CccApiClient _apiClient;
private readonly ILibraryManager _libraryManager;
private readonly ILogger<ChaosflixSimilarItems> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ChaosflixSimilarItems"/> class.
/// </summary>
public ChaosflixSimilarItems(
CccApiClient apiClient, ILibraryManager libraryManager, ILogger<ChaosflixSimilarItems> logger)
{
_apiClient = apiClient;
_libraryManager = libraryManager;
_logger = logger;
}
/// <inheritdoc />
public string Name => ChaosflixChannel.ChannelName;
/// <inheritdoc />
public MetadataPluginType Type => MetadataPluginType.LocalSimilarityProvider;
/// <summary>
/// Gets how long Jellyfin may reuse an answer. The recommendations change when
/// media.ccc.de recomputes them, which is far slower than people browse.
/// </summary>
public TimeSpan? CacheDuration => TimeSpan.FromHours(6);
/// <inheritdoc />
public bool Supports(Type itemType) => typeof(Video).IsAssignableFrom(itemType);
/// <inheritdoc />
public async Task<IReadOnlyList<BaseItem>> GetSimilarItemsAsync(
BaseItem item, SimilarItemsQuery query, CancellationToken cancellationToken)
{
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
if (eventGuid == null)
{
return Array.Empty<BaseItem>();
}
var cccEvent = await _apiClient.GetEventAsync(eventGuid, cancellationToken).ConfigureAwait(false);
if (cccEvent?.Related == null || cccEvent.Related.Count == 0)
{
return Array.Empty<BaseItem>();
}
var stored = StoredTalks(item.ChannelId);
var excluded = query.ExcludeItemIds?.ToHashSet() ?? new HashSet<Guid>();
var similar = cccEvent.Related
.OrderByDescending(r => r.Weight)
.Select(r => stored.GetValueOrDefault(r.EventGuid))
.OfType<BaseItem>()
.Where(i => i.Id != item.Id && !excluded.Contains(i.Id))
.Take(query.Limit ?? DefaultLimit)
.ToList();
_logger.LogDebug(
"Chaosflix: {Stored} of {Related} related talks of {Title} are in the library",
similar.Count,
cccEvent.Related.Count,
item.Name);
return similar;
}
/// <summary>
/// The talks of this channel that Jellyfin has stored, by CCC event guid. Only a
/// talk somebody has browsed to — or that the sync walked in (#8) — is in here at
/// all; a recommendation pointing anywhere else is simply left out, because a
/// similar-items provider can only answer with items that exist.
/// </summary>
private Dictionary<string, BaseItem> StoredTalks(Guid channelId)
{
var talks = new Dictionary<string, BaseItem>(StringComparer.Ordinal);
foreach (var stored in _libraryManager.GetItemList(new InternalItemsQuery { ChannelIds = [channelId] }))
{
var guid = ChaosflixChannel.EventGuidOf(stored.ExternalId);
if (guid == null)
{
continue;
}
// A talk is its own library item in every folder that lists it, so the same
// guid turns up several times. The conference copy is the canonical one.
if (!talks.ContainsKey(guid) || ChaosflixChannel.IsConferenceCopy(stored.ExternalId))
{
talks[guid] = stored;
}
}
return talks;
}
}

View File

@@ -0,0 +1,221 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.Chaosflix.Api;
using Jellyfin.Plugin.Chaosflix.Api.Models;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Channels;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Chaosflix.Channel;
/// <summary>
/// Scheduled task that refreshes the CCC API cache for the conferences the user follows
/// and then walks them once, so Jellyfin stores their talks as library items. Entries are
/// replaced one at a time, so cached event details and resolved CDN redirects survive a run.
///
/// The walk is what makes a talk findable by name: Jellyfin creates a channel item the
/// first time a client asks for the folder holding it and never before (measured — neither
/// its own channel refresh nor a library scan descends into channel content), so without
/// this a talk is only searchable once somebody has clicked their way to it (#8).
/// </summary>
public class ChaosflixSyncTask : IScheduledTask
{
/// <summary>
/// Interval between runs. <see cref="CccApiClient.ConferenceCacheTtl"/> outlives it, so the
/// data this task caches is still there when the next run becomes due.
/// </summary>
public static readonly TimeSpan SyncInterval = TimeSpan.FromHours(6);
/// <summary>
/// Identifier the server files this task under; the status endpoint looks it up by it.
/// </summary>
public const string TaskKey = "ChaosflixSync";
/// <summary>
/// How many conferences are refreshed and walked when the user has named none. The
/// whole catalogue is several hundred conferences, and expanding every one of them
/// four times a day would be a poor way to treat an API the CCC runs on donated time.
/// Naming conferences in the settings lifts this: then exactly those are walked (#86).
/// </summary>
internal const int DefaultConferenceCount = 20;
private readonly CccApiClient _apiClient;
private readonly IChannelManager _channelManager;
private readonly ILogger<ChaosflixSyncTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ChaosflixSyncTask"/> class.
/// </summary>
public ChaosflixSyncTask(
CccApiClient apiClient, IChannelManager channelManager, ILogger<ChaosflixSyncTask> logger)
{
_apiClient = apiClient;
_channelManager = channelManager;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Chaosflix: Sync CCC Media";
/// <inheritdoc />
public string Key => TaskKey;
/// <inheritdoc />
public string Description => "Refreshes the conference and talk cache from media.ccc.de";
/// <inheritdoc />
public string Category => "Chaosflix";
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
return new[]
{
new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = SyncInterval.Ticks
}
};
}
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
_logger.LogInformation("Chaosflix sync: starting cache refresh");
progress.Report(0);
var selected = await RefreshConferencesAsync(progress, cancellationToken).ConfigureAwait(false);
await IngestAsync(selected, progress, cancellationToken).ConfigureAwait(false);
progress.Report(100);
}
private async Task<List<CccConference>> RefreshConferencesAsync(
IProgress<double> progress, CancellationToken cancellationToken)
{
var all = await _apiClient.RefreshConferencesAsync(cancellationToken).ConfigureAwait(false);
_logger.LogInformation("Chaosflix sync: found {Count} conferences", all.Count);
progress.Report(5);
var filter = Plugin.Instance?.Configuration.ConferenceFilter;
var selected = ChaosflixChannel.SelectConferences(all, filter)
.Where(c => c.EventLastReleasedAt != null)
.OrderByDescending(c => c.EventLastReleasedAt)
.ToList();
if (string.IsNullOrWhiteSpace(filter))
{
selected = selected.Take(DefaultConferenceCount).ToList();
}
var refreshed = 0;
for (var i = 0; i < selected.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var conf = selected[i];
try
{
var detail = await _apiClient.RefreshConferenceAsync(conf.Acronym, cancellationToken).ConfigureAwait(false);
refreshed++;
_logger.LogDebug("Chaosflix sync: cached {Title} ({EventCount} events)",
conf.Title, detail?.Events?.Count ?? 0);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
_logger.LogWarning(ex, "Chaosflix sync: failed to fetch {Acronym}", conf.Acronym);
}
progress.Report(5 + (55.0 * (i + 1) / selected.Count));
}
_logger.LogInformation("Chaosflix sync: completed — {Count} of {Total} conferences cached",
refreshed, selected.Count);
return selected;
}
/// <summary>
/// Asks Jellyfin for the children of every selected conference folder, which is what
/// makes it store their talks. The walk goes top down because a folder can only be
/// addressed by the item id Jellyfin gave it, and that id does not exist until its
/// parent has been listed once.
/// </summary>
private async Task IngestAsync(
List<CccConference> conferences, IProgress<double> progress, CancellationToken cancellationToken)
{
if (conferences.Count == 0)
{
return;
}
var channels = await _channelManager.GetChannelsInternalAsync(new ChannelQuery()).ConfigureAwait(false);
var channel = channels.Items
.FirstOrDefault(c => string.Equals(c.Name, ChaosflixChannel.ChannelName, StringComparison.Ordinal));
if (channel == null)
{
_logger.LogDebug("Chaosflix sync: the channel is not registered yet, nothing to ingest");
return;
}
var wanted = conferences
.Select(c => ChaosflixChannel.ConferenceFolderId(c.Acronym))
.ToHashSet(StringComparer.Ordinal);
var root = await ChildrenAsync(channel.Id, Guid.Empty, cancellationToken).ConfigureAwait(false);
var years = root.FirstOrDefault(i =>
string.Equals(i.ExternalId, ChaosflixChannel.FolderBrowseByYear, StringComparison.Ordinal));
if (years == null)
{
_logger.LogWarning("Chaosflix sync: the channel root has no year folder, skipping the walk");
return;
}
var yearFolders = await ChildrenAsync(channel.Id, years.Id, cancellationToken).ConfigureAwait(false);
var walked = 0;
foreach (var yearFolder in yearFolders)
{
cancellationToken.ThrowIfCancellationRequested();
var conferenceFolders = await ChildrenAsync(channel.Id, yearFolder.Id, cancellationToken)
.ConfigureAwait(false);
foreach (var folder in conferenceFolders.Where(f => wanted.Contains(f.ExternalId ?? string.Empty)))
{
var talks = await ChildrenAsync(channel.Id, folder.Id, cancellationToken).ConfigureAwait(false);
walked++;
_logger.LogDebug("Chaosflix sync: stored {Count} talks of {Conference}",
talks.Count, folder.Name);
progress.Report(60 + (40.0 * walked / conferences.Count));
}
}
_logger.LogInformation("Chaosflix sync: walked {Count} of {Total} conferences into the library",
walked, conferences.Count);
}
private async Task<IReadOnlyList<BaseItem>> ChildrenAsync(
Guid channelId, Guid parentId, CancellationToken cancellationToken)
{
var query = new InternalItemsQuery
{
ChannelIds = [channelId],
ParentId = parentId
};
var result = await _channelManager
.GetChannelItemsInternal(query, new Progress<double>(), cancellationToken)
.ConfigureAwait(false);
return result.Items;
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Chaosflix.Channel;
/// <summary>
/// Keeps the watch state of one talk in sync across the copies the channel
/// hands out.
/// </summary>
/// <remarks>
/// Jellyfin stores a channel item as one library item with exactly one parent,
/// so a talk listed in several folders needs one id per folder (#15) and ends up
/// as several library items — each with its own user data. Marking a talk
/// unwatched or clearing its position then only affects the copy in front of the
/// user (#53). This mirrors played state and resume position from the copy that
/// was saved to its siblings.
/// </remarks>
public sealed class ChaosflixUserDataMirror : IHostedService
{
private readonly IUserDataManager _userDataManager;
private readonly ILibraryManager _libraryManager;
private readonly IUserManager _userManager;
private readonly ILogger<ChaosflixUserDataMirror> _logger;
// Jellyfin raises UserDataSaved from inside SaveUserData, so a mirrored save
// comes back as another event; the copies it is about are in here while it runs.
private readonly ConcurrentDictionary<(Guid User, Guid Item), byte> _mirroring = new();
/// <summary>
/// Initializes a new instance of the <see cref="ChaosflixUserDataMirror"/> class.
/// </summary>
/// <param name="userDataManager">Source of the save events and sink for the mirrored state.</param>
/// <param name="libraryManager">Used to find the other copies of a talk.</param>
/// <param name="userManager">Resolves the user a save belongs to.</param>
/// <param name="logger">Logger.</param>
public ChaosflixUserDataMirror(
IUserDataManager userDataManager,
ILibraryManager libraryManager,
IUserManager userManager,
ILogger<ChaosflixUserDataMirror> logger)
{
_userDataManager = userDataManager;
_libraryManager = libraryManager;
_userManager = userManager;
_logger = logger;
}
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved += OnUserDataSaved;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task StopAsync(CancellationToken cancellationToken)
{
_userDataManager.UserDataSaved -= OnUserDataSaved;
return Task.CompletedTask;
}
internal void OnUserDataSaved(object? sender, UserDataSaveEventArgs e)
{
try
{
Mirror(e);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not mirror the watch state of {Item}", e.Item?.Name);
}
}
internal void Mirror(UserDataSaveEventArgs e)
{
var item = e.Item;
var source = e.UserData;
if (item is null || source is null || !IsWatchState(e.SaveReason))
{
return;
}
var eventGuid = ChaosflixChannel.EventGuidOf(item.ExternalId);
if (eventGuid is null || _mirroring.ContainsKey((e.UserId, item.Id)))
{
return;
}
var user = _userManager.GetUserById(e.UserId);
if (user is null)
{
return;
}
foreach (var sibling in Siblings(item, eventGuid))
{
var target = _userDataManager.GetUserData(user, sibling);
if (target is null || !Differs(source, target))
{
continue;
}
target.Played = source.Played;
target.PlaybackPositionTicks = source.PlaybackPositionTicks;
target.PlayCount = source.PlayCount;
target.LastPlayedDate = source.LastPlayedDate;
if (!_mirroring.TryAdd((e.UserId, sibling.Id), 0))
{
continue;
}
try
{
_userDataManager.SaveUserData(user, sibling, target, UserDataSaveReason.UpdateUserData, CancellationToken.None);
}
finally
{
_mirroring.TryRemove((e.UserId, sibling.Id), out _);
}
}
}
// Import is a bulk reason and carries no user action; the rest is what a
// client does when it plays a talk or toggles its watched state.
private static bool IsWatchState(UserDataSaveReason reason) => reason is
UserDataSaveReason.PlaybackStart or
UserDataSaveReason.PlaybackProgress or
UserDataSaveReason.PlaybackFinished or
UserDataSaveReason.TogglePlayed or
UserDataSaveReason.UpdateUserData;
// A save whose state already matches produces no save of its own, which is
// what stops a mirrored save from travelling back and forth.
private static bool Differs(UserItemData source, UserItemData target) =>
source.Played != target.Played ||
source.PlaybackPositionTicks != target.PlaybackPositionTicks ||
source.PlayCount != target.PlayCount ||
source.LastPlayedDate != target.LastPlayedDate;
private IEnumerable<BaseItem> Siblings(BaseItem item, string eventGuid)
{
// Looked up by name within the channel rather than by scanning every
// channel item: the scopes a talk can appear under are open-ended
// (related-<guid> exists per talk), so its other ids cannot be enumerated.
var query = new InternalItemsQuery
{
Name = item.Name,
ChannelIds = item.ChannelId == Guid.Empty ? Array.Empty<Guid>() : new[] { item.ChannelId }
};
IReadOnlyList<BaseItem> candidates;
try
{
candidates = _libraryManager.GetItemList(query);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not look up the other copies of {Item}", item.Name);
return Array.Empty<BaseItem>();
}
return candidates
.Where(candidate =>
!candidate.Id.Equals(item.Id) &&
string.Equals(ChaosflixChannel.EventGuidOf(candidate.ExternalId), eventGuid, StringComparison.Ordinal))
// Through the library cache, not as the query materialised them: user
// data is read off the BaseItem instance the cache holds, so saving
// against a fresh instance reaches the database but leaves what the
// server serves stale until a restart.
.Select(candidate => _libraryManager.GetItemById(candidate.Id) ?? candidate);
}
}

View File

@@ -0,0 +1,37 @@
using System.Net.Http;
using Jellyfin.Plugin.Chaosflix.Api;
using Jellyfin.Plugin.Chaosflix.Channel;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Channels;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Plugins;
using Microsoft.Extensions.DependencyInjection;
namespace Jellyfin.Plugin.Chaosflix;
/// <summary>
/// Registers plugin services in the DI container.
/// </summary>
public class ChaosflixServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// Redirect resolution needs the 302 itself, not the mirror behind it.
serviceCollection
.AddHttpClient(CccApiClient.RedirectClientName)
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false });
serviceCollection.AddSingleton<CccApiClient>();
// The status endpoint reports this channel's caches, so it has to reach the very
// instance Jellyfin serves from — registering the class twice would give it a
// second one with empty caches.
serviceCollection.AddSingleton<ChaosflixChannel>();
serviceCollection.AddSingleton<IChannel>(sp => sp.GetRequiredService<ChaosflixChannel>());
// Jellyfin collects the similar-items providers out of the container, the same
// way it collects channels.
serviceCollection.AddSingleton<ISimilarItemsProvider, ChaosflixSimilarItems>();
serviceCollection.AddHostedService<ChaosflixUserDataMirror>();
}
}

View File

@@ -0,0 +1,76 @@
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.Chaosflix.Configuration;
/// <summary>
/// Plugin configuration.
/// </summary>
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// Gets or sets the preferred video quality.
/// </summary>
public VideoQuality PreferredQuality { get; set; } = VideoQuality.High;
/// <summary>
/// Gets or sets the preferred language (ISO 639 code, e.g. "deu", "eng").
/// Empty means original language.
/// </summary>
public string PreferredLanguage { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the preferred video format.
/// </summary>
public VideoFormat PreferredFormat { get; set; } = VideoFormat.Mp4;
/// <summary>
/// Gets or sets a value indicating whether a talk starts on a version every client
/// can stream without a re-encode, leaving <see cref="PreferredFormat"/> to order
/// the versions behind it.
/// </summary>
public bool CompatibleDefaultVersion { get; set; }
/// <summary>
/// Gets or sets the conferences to show, separated by commas or newlines. An entry
/// is either an acronym ("38c3") or a series as media.ccc.de files it in the slug
/// ("congress", "gpn"), which also covers the editions not published yet.
/// Empty means every conference, which is what the plugin did before this existed.
/// </summary>
public string ConferenceFilter { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the streaming endpoint that reports which rooms are on air.
/// Empty means the public one at streaming.media.ccc.de.
/// </summary>
public string StreamingBaseUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the CCC API endpoint. Empty means the public API at media.ccc.de;
/// set it to use a mirror or, in the e2e tests, a local stand-in.
/// </summary>
public string ApiBaseUrl { get; set; } = string.Empty;
}
/// <summary>
/// Video quality preference.
/// </summary>
public enum VideoQuality
{
/// <summary>High quality (1080p).</summary>
High,
/// <summary>Standard quality (576p).</summary>
Standard
}
/// <summary>
/// Video format preference.
/// </summary>
public enum VideoFormat
{
/// <summary>H.264 MP4.</summary>
Mp4,
/// <summary>WebM/VP9.</summary>
WebM
}

View File

@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html>
<head>
<title>Chaosflix</title>
</head>
<body>
<div id="ChaosflixConfigPage" data-role="page" class="page type-interior pluginConfigurationPage"
data-require="emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="ChaosflixConfigForm">
<div class="verticalSection">
<h2 class="sectionTitle">Chaosflix Settings</h2>
<div class="selectContainer">
<label class="selectLabel" for="PreferredQuality">Preferred Quality</label>
<select is="emby-select" id="PreferredQuality" class="emby-select-withcolor">
<option value="High">High (1080p)</option>
<option value="Standard">Standard (576p)</option>
</select>
</div>
<div class="selectContainer">
<label class="selectLabel" for="PreferredFormat">Preferred Format</label>
<select is="emby-select" id="PreferredFormat" class="emby-select-withcolor">
<option value="Mp4">MP4 (H.264)</option>
<option value="WebM">WebM (VP9)</option>
</select>
<div class="fieldDescription">WebM costs a full re-encode on clients that can only transcode to H.264 — the Android app is one of them.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input is="emby-checkbox" type="checkbox" id="CompatibleDefaultVersion" />
<span>Start on a version every client can play untouched</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
Jellyfin hands every client the same list of versions, so the plugin cannot order it per device. With this on a talk starts on its H.264 MP4, which no client has to re-encode, and the preferred format only orders the versions behind it. Leave it off if your clients decode VP9 but not H.264.
</div>
</div>
<div class="selectContainer">
<label class="selectLabel" for="PreferredLanguage">Preferred Language</label>
<select is="emby-select" id="PreferredLanguage" class="emby-select-withcolor">
<option value="">Original</option>
<option value="deu">Deutsch</option>
<option value="eng">English</option>
</select>
</div>
<div class="inputContainer">
<label class="inputLabel" for="ConferenceFilter">Conferences</label>
<input is="emby-input" type="text" id="ConferenceFilter" placeholder="congress, gpn, 38c3" />
<div class="fieldDescription">Leave empty for every conference media.ccc.de publishes. Otherwise list what you follow, separated by commas: a series as it appears in the media.ccc.de address ("congress", "gpn", "easterhegg") keeps every edition including future ones, an acronym ("38c3") keeps exactly that one. This also narrows Popular and Recommended.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="StreamingBaseUrl">Streaming Endpoint</label>
<input is="emby-input" type="text" id="StreamingBaseUrl" placeholder="https://streaming.media.ccc.de" />
<div class="fieldDescription">Where the plugin asks which rooms are on air. Leave empty for streaming.media.ccc.de. A "🔴 Live now" folder appears while a congress is running and disappears again afterwards.</div>
</div>
<div class="inputContainer">
<label class="inputLabel" for="ApiBaseUrl">API Endpoint</label>
<input is="emby-input" type="text" id="ApiBaseUrl" placeholder="https://api.media.ccc.de/public" />
<div class="fieldDescription">Leave empty for media.ccc.de. Set it to use a mirror.</div>
</div>
</div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
<div class="verticalSection">
<h2 class="sectionTitle">Status</h2>
<div class="fieldDescription">What the plugin thinks is true right now. Nothing here talks to media.ccc.de until you ask it to.</div>
<div id="ChaosflixStatus" class="paperList" style="padding: 0.5em 1em;">Loading…</div>
<div>
<button is="emby-button" type="button" id="ChaosflixRefresh" class="raised emby-button">
<span>Refresh</span>
</button>
<button is="emby-button" type="button" id="ChaosflixCheckApi" class="raised emby-button">
<span>Check endpoint now</span>
</button>
<button is="emby-button" type="button" id="ChaosflixClearCaches" class="raised emby-button">
<span>Clear caches</span>
</button>
</div>
<div id="ChaosflixApiCheck" class="fieldDescription"></div>
</div>
<div class="verticalSection">
<h2 class="sectionTitle">Check a talk</h2>
<div class="fieldDescription">Resolves one talk the way playback does — API lookup, recording choice, signed proxy url, proxy request, probe — and names the stage that fails.</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="ChaosflixTalkId">Talk guid, slug or media.ccc.de url</label>
<input is="emby-input" type="text" id="ChaosflixTalkId" />
</div>
<div>
<button is="emby-button" type="button" id="ChaosflixCheckTalk" class="raised emby-button">
<span>Check this talk</span>
</button>
</div>
<div id="ChaosflixTalkCheck" class="fieldDescription"></div>
</div>
</div>
</div>
<script type="text/javascript">
var ChaosflixConfig = {
pluginUniqueId: 'c4a05f11-4ccc-4b00-bdea-dbeef1337000',
statusEndpoint: 'api/ChaosflixStatus'
};
(function () {
var page = document.querySelector('#ChaosflixConfigPage');
function statusUrl(action) {
return ApiClient.getUrl(ChaosflixConfig.statusEndpoint + (action ? '/' + action : ''));
}
function post(action, query) {
return ApiClient.ajax({
type: 'POST',
url: statusUrl(action) + (query || ''),
dataType: 'json'
});
}
// Talk titles and error messages come from media.ccc.de, so every value
// is written as text and never as markup.
function row(container, label, value) {
var line = document.createElement('div');
var name = document.createElement('strong');
name.textContent = label + ': ';
line.appendChild(name);
line.appendChild(document.createTextNode(value));
container.appendChild(line);
}
function percent(value) {
return value === null || value === undefined ? 'n/a' : value + '%';
}
function renderStatus(status) {
var target = page.querySelector('#ChaosflixStatus');
target.textContent = '';
row(target, 'Plugin version', status.Version);
row(target, 'Endpoint', status.ApiBaseUrl + (status.ApiBaseUrlIsDefault ? ' (default)' : ' (configured)'));
var sync = status.Sync;
if (!sync.Registered) {
row(target, 'Sync task', 'not registered with this server');
} else if (!sync.LastRunUtc) {
row(target, 'Sync task', sync.State + ', has not run yet');
} else {
row(target, 'Sync task', sync.LastResult + ' — ' + new Date(sync.LastRunUtc).toLocaleString() +
', took ' + sync.DurationSeconds + 's (now ' + sync.State + ')');
if (sync.Error) {
row(target, 'Sync error', sync.Error);
}
}
var cache = status.ApiCache;
row(target, 'API cache', cache.Entries + '/' + cache.Capacity + ' entries — ' +
cache.Conferences + ' conferences, ' + cache.Events + ' talks, ' +
cache.Searches + ' searches, ' + cache.Redirects + ' CDN mirrors');
row(target, 'API cache hit rate', percent(cache.HitRatePercent) +
' (' + cache.Hits + ' hits, ' + cache.Misses + ' misses since server start)');
var probe = status.ProbeCache;
row(target, 'Probe cache', probe.Entries + '/' + probe.Capacity + ' stream layouts, hit rate ' +
percent(probe.HitRatePercent) + ' (' + probe.Hits + ' hits, ' + probe.Misses + ' misses)');
row(target, 'Media sources handed out', probe.ServedItems +
' talks a configuration change or a cache clear would invalidate');
}
function loadStatus() {
return ApiClient.getJSON(statusUrl()).then(renderStatus, function () {
page.querySelector('#ChaosflixStatus').textContent = 'The status endpoint did not answer.';
});
}
function renderTalkCheck(result) {
var target = page.querySelector('#ChaosflixTalkCheck');
target.textContent = '';
row(target, result.Ok ? 'Result' : 'Failed at',
result.Ok ? 'resolved end to end' : result.FailedStage);
if (result.Title) {
row(target, 'Talk', result.Title);
}
result.Stages.forEach(function (stage) {
row(target, (stage.Ok ? '✓ ' : '✗ ') + stage.Name,
stage.Detail + (stage.ElapsedMs ? ' (' + stage.ElapsedMs + ' ms)' : ''));
});
}
page.addEventListener('pageshow', function () {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(ChaosflixConfig.pluginUniqueId).then(function (config) {
page.querySelector('#PreferredQuality').value = config.PreferredQuality || 'High';
page.querySelector('#PreferredFormat').value = config.PreferredFormat || 'Mp4';
page.querySelector('#PreferredLanguage').value = config.PreferredLanguage || '';
page.querySelector('#CompatibleDefaultVersion').checked = config.CompatibleDefaultVersion === true;
page.querySelector('#ConferenceFilter').value = config.ConferenceFilter || '';
page.querySelector('#StreamingBaseUrl').value = config.StreamingBaseUrl || '';
page.querySelector('#ApiBaseUrl').value = config.ApiBaseUrl || '';
Dashboard.hideLoadingMsg();
});
// Cheap by construction: the endpoint only reads counters the plugin
// already keeps. The checks below are the ones that cost something,
// and they run on click.
loadStatus();
});
page.querySelector('#ChaosflixRefresh').addEventListener('click', loadStatus);
page.querySelector('#ChaosflixCheckApi').addEventListener('click', function () {
var target = page.querySelector('#ChaosflixApiCheck');
target.textContent = 'Asking the endpoint…';
post('CheckApi').then(function (result) {
target.textContent = '';
row(target, result.Ok ? 'Reachable' : 'Unreachable', result.Url);
row(target, 'Round trip', result.ElapsedMs + ' ms');
if (result.Ok) {
row(target, 'Answer', result.StatusCode + ', ' + result.ConferenceCount + ' conferences listed');
} else {
row(target, 'Error', (result.StatusCode ? result.StatusCode + ' ' : '') + (result.Error || 'no answer'));
}
}, function () {
target.textContent = 'The check could not be run.';
});
});
page.querySelector('#ChaosflixClearCaches').addEventListener('click', function () {
Dashboard.showLoadingMsg();
post('ClearCaches').then(function (status) {
renderStatus(status);
Dashboard.hideLoadingMsg();
}, function () {
Dashboard.hideLoadingMsg();
page.querySelector('#ChaosflixStatus').textContent = 'Clearing the caches failed.';
});
});
page.querySelector('#ChaosflixCheckTalk').addEventListener('click', function () {
var id = page.querySelector('#ChaosflixTalkId').value.trim();
var target = page.querySelector('#ChaosflixTalkCheck');
if (!id) {
target.textContent = 'Enter a talk guid, slug or media.ccc.de url first.';
return;
}
target.textContent = 'Resolving ' + id + '…';
post('CheckTalk', '?id=' + encodeURIComponent(id)).then(renderTalkCheck, function () {
target.textContent = 'The check could not be run.';
});
});
page.querySelector('#ChaosflixConfigForm').addEventListener('submit', function (e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(ChaosflixConfig.pluginUniqueId).then(function (config) {
config.PreferredQuality = page.querySelector('#PreferredQuality').value;
config.PreferredFormat = page.querySelector('#PreferredFormat').value;
config.PreferredLanguage = page.querySelector('#PreferredLanguage').value;
config.CompatibleDefaultVersion = page.querySelector('#CompatibleDefaultVersion').checked;
config.ConferenceFilter = page.querySelector('#ConferenceFilter').value;
config.StreamingBaseUrl = page.querySelector('#StreamingBaseUrl').value;
config.ApiBaseUrl = page.querySelector('#ApiBaseUrl').value;
ApiClient.updatePluginConfiguration(ChaosflixConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
loadStatus();
});
});
e.preventDefault();
return false;
});
})();
</script>
</div>
</body>
</html>

View File

@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Chaosflix</RootNamespace>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<Nullable>enable</Nullable>
</PropertyGroup>
<!--
Built against the oldest supported server (targetAbi in meta.json), so the
compiler rejects APIs that a server on that version would not have.
-->
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="12.1.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="12.1.0">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Jellyfin.Plugin.Chaosflix.Tests" />
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.Chaosflix.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.Chaosflix;
/// <summary>
/// Chaosflix plugin — streams CCC media content into Jellyfin.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <inheritdoc />
public override string Name => "Chaosflix";
/// <inheritdoc />
public override Guid Id => Guid.Parse("c4a05f11-4ccc-4b00-bdea-dbeef1337000");
/// <inheritdoc />
public override string Description => "Browse and stream Chaos Computer Club conference talks directly in Jellyfin.";
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
return
[
new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.configPage.html",
GetType().Namespace)
}
];
}
}

View File

@@ -0,0 +1,13 @@
{
"guid": "c4a05f11-4ccc-4b00-bdea-dbeef1337000",
"name": "Chaosflix",
"description": "Browse and stream Chaos Computer Club conference talks from media.ccc.de",
"overview": "CCC Media Channel for Jellyfin",
"owner": "praetorianer777",
"category": "Channels",
"version": "0.4.0.0",
"targetAbi": "12.1.0.0",
"status": "Active",
"autoUpdate": true,
"imageUrl": "https://raw.githubusercontent.com/praetorianer777/chaosflix-jellyfin/main/assets/logo.svg"
}

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Steve Berthold
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,420 @@
# 📺 Chaosflix — CCC Media Plugin for Jellyfin
[![CI](https://github.com/praetorianer777/chaosflix-jellyfin/actions/workflows/ci.yml/badge.svg)](https://github.com/praetorianer777/chaosflix-jellyfin/actions/workflows/ci.yml)
Browse and stream **Chaos Computer Club** conference talks directly in Jellyfin.
All content is sourced from [media.ccc.de](https://media.ccc.de) via their public API — no downloads, no local storage needed.
## Features
- 🎬 **Stream talks** from the CCC CDN through the server — no downloads, no local storage
- 🔴 **Live now** — while a congress is running, the rooms on air with what is
playing in them; the folder is absent the rest of the year
- 🔥 **Popular Talks** — most viewed talks across conferences
- ⭐ **Recommended** — trending talks ranked by views and recency
- 📅 **Browse by Year** — conferences grouped by year (2024 → 38C3, Camp…)
- 📂 **Browse by Conference** — 38C3, Camp 2019, FOSSGIS, and hundreds more
- 🔗 **Related Talks** — CCC's weighted recommendations fill the "More like this"
row of any client, for the talks your library holds
- 🏷️ **Tags as Genres** — filter by topic (security, ethics, hardware…)
- 👤 **Browse by speaker** — every talk a person has given, across all conferences
- 🕐 **Watch history & resume** — powered by Jellyfin (per-user, cross-device)
- 👥 **SyncPlay** — watch together with multiple users
- 🆕 **Latest talks** — newly released recordings on your home screen
- 🔄 **Scheduled sync** — background task keeps cache fresh (every 6h)
- ⚙️ **Quality & format preferences** — HD/SD, MP4/WebM, language selection
- 🎚️ **Every recording selectable** — pick quality or language per playback from the player's version list
## Installation
### Option 1: Jellyfin Plugin Repository (recommended)
1. Open Jellyfin **Dashboard → Plugins → Repositories**
2. Click **Add** and enter:
- **Name:** `Chaosflix`
- **URL:** `https://raw.githubusercontent.com/praetorianer777/chaosflix-jellyfin/main/manifest.json`
3. Go to **Catalog → Channels** and install **Chaosflix**
4. Restart Jellyfin
5. The **Chaosflix** channel appears under **Home → My Media → Channels**
### Option 2: Manual Installation
1. Build the plugin (see [Building](#building) below)
2. Create the plugin directory and copy files:
```bash
# Linux (standalone)
mkdir -p ~/.local/share/jellyfin/plugins/Chaosflix
cp artifacts/Jellyfin.Plugin.Chaosflix.dll artifacts/meta.json \
~/.local/share/jellyfin/plugins/Chaosflix/
# Docker
mkdir -p /path/to/jellyfin-config/plugins/Chaosflix
cp artifacts/Jellyfin.Plugin.Chaosflix.dll artifacts/meta.json \
/path/to/jellyfin-config/plugins/Chaosflix/
```
3. Restart Jellyfin
4. The **Chaosflix** channel appears under **Home → My Media → Channels**
## Troubleshooting
**The channel is missing for one user.** Jellyfin decides per user which channels
exist at all: Dashboard → Users → _user_ → Access → **Channel Access**. When
Chaosflix is not ticked the server leaves it out of that user's views entirely,
so clients show nothing rather than an error. New users get access to all
channels by default, so this only bites where an admin once picked channels by
hand — a plugin installed afterwards is not added to that list.
**The channel is missing on one device but visible elsewhere.** The Android TV
app builds its library row once and registers no refresh trigger, so a client
that has been running since before the plugin was installed keeps its old set of
libraries. Force-stop the app (Settings → Apps → Jellyfin → Force stop) and
reopen it; returning to the launcher is not enough.
**A talk does not play.** Dashboard → Plugins → Chaosflix shows what the plugin
believes: whether media.ccc.de answers, what is cached, and a **check a talk**
box that resolves one talk end to end — API lookup, recording choice, signed
proxy url, proxy reachability and stream probe — and names the stage that fails.
## Channel Structure
```
Chaosflix
├── 🔴 Live now ← only while a congress is on air
│ ├── Saal 1: current talk ▶️
│ └── Saal ZIGZAG: … ▶️
├── 🔥 Popular Talks ← Top 50 by view count
├── ⭐ Recommended ← Trending (views × recency)
└── 📅 Browse by Year
├── 2024
│ ├── 38C3: Illegal Instructions
│ │ ├── Talk 1 ▶️
│ │ ├── Talk 2 ▶️
│ │ └── ...
│ ├── FOSSGIS 2024
│ └── ...
├── 2023
│ ├── 37C3: Unlocked
│ └── ...
└── ...
```
### Speakers and topics
Talks are not only reachable through the folders. Every talk carries its
speakers and its tags, and Jellyfin indexes both, so two more ways in exist
without the channel offering a folder for them.
**By speaker** works in any client: open a talk, click a name in its cast list,
and you get that person's other talks — across every conference your library
holds, not just the one you came from. Over the API that is
`/Persons` for the list and `/Items?personIds=<id>&recursive=true` for one
person's talks.
**By topic** works as a filter, but there is no list to pick from:
`/Items?genres=Security` returns the right talks, while Jellyfin's genre
browse stays empty for them — see Known Limitations.
Neither costs anything: they use the talks already in the library rather than
listing them a second time, which a channel folder would (see Known
Limitations). Which talks are in the library is decided by the **Conferences**
setting below.
## Configuration
Go to **Dashboard → Plugins → Chaosflix** to set:
| Setting | Options | Default |
|---------|---------|---------|
| Preferred Quality | HD (1080p) / SD (576p) | HD |
| Preferred Format | MP4 (H.264) / WebM (VP9) | MP4 |
| Preferred Language | Original / Deutsch / English | Original |
| Start on a version every client can play untouched | on / off | off |
| Conferences | comma-separated list | empty — every conference |
| Streaming Endpoint | url | empty — streaming.media.ccc.de |
**Conferences** narrows the whole channel to what you actually follow.
media.ccc.de publishes hundreds of them, and Browse by Year lists every one.
An entry is either a series as it appears in a media.ccc.de address —
`congress` for `media.ccc.de/c/congress/2025`, and likewise `gpn`,
`easterhegg` — which keeps every edition including the ones not announced yet,
or a single acronym like `38c3`. The filter also applies to Popular,
Recommended and the latest row, so they are drawn from the same conferences as
the folders, and the plugin stops fetching the details of everything else.
Left empty, nothing is filtered.
**Streaming Endpoint** is where the plugin asks which rooms are on air, which
is what fills 🔴 Live now. Left empty it uses the public one at
`streaming.media.ccc.de`; point it elsewhere for a mirror, or at a local
stand-in when testing. Outside a congress the endpoint reports nothing and the
folder is not shown at all.
Naming your conferences also makes their talks **findable by name**. Jellyfin
creates a channel item the first time something asks for the folder holding it
and at no other time, so a talk nobody has browsed to cannot be searched for.
The scheduled sync walks the conferences you named once per run, which puts
them in the library and therefore in Jellyfin's search. With no conferences
named it walks the 20 most recent ones instead — expanding all of media.ccc.de
four times a day would be a poor way to treat an API the CCC runs on donated
time.
Every recording of a talk is offered as its own version ("HD MP4 · Deutsch",
"SD WebM · English") in the player's version selector; these settings decide
which of them is the default. A viewer can pick another one per playback and
per device without changing anything here.
The order is the same for every client: Jellyfin builds a talk's version list
once and hands it to whoever asks next, so the plugin cannot sort it per
device. That matters for WebM, because a client whose only transcoding video
codec is H.264 — the Android app — has to re-encode VP9/Opus for the whole
talk. Preferring MP4, or the setting above, keeps the default version one that
every client streams untouched while WebM stays selectable.
### Status
The same page reports what the plugin currently believes to be true: the
endpoint in effect, when the sync task last ran and how long it took, what the
API and probe caches hold and how often they answer. Three actions cost
something and therefore only run when clicked — **Check endpoint now** (does
the endpoint answer, and how fast), **Clear caches** (the same invalidation a
configuration change triggers) and **Check a talk**, which resolves one talk
the way playback does (API lookup → recording choice → signed proxy url →
proxy request → probe) and names the stage that fails.
### Scheduled Sync
The plugin automatically syncs conference data every **6 hours** via a scheduled task.
You can trigger a manual sync in **Dashboard → Scheduled Tasks → Chaosflix: Sync CCC Media**.
## Building
### With Dockerfile (recommended)
```bash
# Standard build — outputs to ./artifacts/
docker build --target artifact --output type=local,dest=./artifacts .
# Behind a corporate SSL proxy? Place .crt files in certs/ first:
cp /usr/local/share/ca-certificates/*.crt certs/
docker build --target artifact --output type=local,dest=./artifacts .
```
### Without Docker
```bash
dotnet publish Jellyfin.Plugin.Chaosflix/Jellyfin.Plugin.Chaosflix.csproj -c Release -o ./artifacts
```
### Rebuilding for a new Jellyfin Version
The plugin must be compiled against the same Jellyfin SDK version as your server.
#### Automatisch (empfohlen)
```bash
# Auto-detect latest Jellyfin version from NuGet
./upgrade-jellyfin.sh
# Or specify a version manually
./upgrade-jellyfin.sh 12.1.0
```
Das Script:
1. Updated NuGet-Pakete im `.csproj`
2. Updated `targetAbi` in `meta.json` — `release.sh` trägt ihn in `manifest.json` nach
3. Zieht Target Framework und Docker-SDK mit, wenn der Major das verlangt
4. Macht einen Test-Build via Docker
5. Zeigt Fehler + Lösungsvorschläge bei Breaking Changes
Danach:
```bash
git add -A && git commit -m "chore: upgrade to Jellyfin 12.1.0"
./release.sh
git push origin main --tags # the release workflow publishes the ZIP
```
#### Manuell
```bash
# 1. Check your Jellyfin server version (Dashboard → General)
# 2. Update the SDK references in the .csproj
sed -i 's/Version="12.1.0"/Version="12.2.0"/g' \
Jellyfin.Plugin.Chaosflix/Jellyfin.Plugin.Chaosflix.csproj
# 3. Update targetAbi in meta.json — the single source of truth; release.sh
# copies it into the manifest entry it writes. Published manifest entries
# keep the targetAbi their ZIP was released with and are never rewritten.
sed -i 's/"targetAbi": "12.1.0.0"/"targetAbi": "12.2.0.0"/' \
Jellyfin.Plugin.Chaosflix/meta.json
# 4. Release
./release.sh
```
#### Was bei Major-Updates brechen kann
| Änderung | Symptom | Fix |
|----------|---------|-----|
| Target Framework (z.B. net10→net11) | `TargetFramework 'netX.0' is not supported` | `tfm_for()` in `upgrade-jellyfin.sh` ergänzen; `.csproj`, Dockerfile und `dotnet-version` in den Workflows ziehen mit |
| Namespace-Umbenennung | `The type or namespace 'X' does not exist` | `using`-Statements anpassen |
| API-Signatur-Änderung | `does not contain a definition for 'X'` | Jellyfin Release Notes lesen, Code anpassen |
| DI-Registration | Plugin wird nicht geladen | `ChaosflixServiceRegistrator.cs` prüfen |
> **Tip:** Check available SDK versions: https://www.nuget.org/packages/Jellyfin.Controller
> Check release notes: https://github.com/jellyfin/jellyfin/releases
### Creating a Release (automated)
```bash
# The version is worked out from the commits since the last tag:
./release.sh
# Look first — prints the version and the notes, writes nothing:
./release.sh --dry-run
# A version given by hand always wins:
./release.sh 0.0.2
# The changelog can still be written by hand; it then overrides the generated one:
./release.sh 0.0.2 "Rebuild for Jellyfin 12.1"
```
This updates all version strings, writes the changelog and the release notes,
commits and tags. It builds nothing: the release workflow builds the plugin from
the tag, publishes the release with the ZIP attached and writes the checksum of
exactly those bytes into `manifest.json`. A ZIP built locally would never be
byte-identical to the published one, so its checksum described bytes nobody
could download (#49).
Releasing therefore needs no .NET SDK and no `zip` on the machine that tags:
```bash
git fetch origin && git reset --hard origin/main # release what the remote has
./release.sh
git push origin main --tags # the tag publishes the release
```
`release.sh` refuses to run when the branch is behind its upstream and names the
missing commits, because a release cut from a stale checkout publishes code the
remote does not have (#64).
Without a version argument the next number is derived from the Conventional
Commit subjects since the previous tag, and the reasoning is printed before
anything is written:
| Since the last tag | Bump |
|--------------------|------|
| `feat!:` or a `BREAKING CHANGE:` trailer | MINOR while below 1.0.0 (a 0.x MAJOR bump would declare 1.0), MAJOR from 1.0.0 on |
| a `feat:` | MINOR |
| a raised `targetAbi` in `meta.json` | MINOR — it changes which servers may install the plugin |
| a `fix:` | PATCH |
| only `chore`/`docs`/`test`/`build`/`ci`/`refactor` | PATCH, and the output says so |
| nothing | the script aborts and changes nothing |
Without a changelog argument the release notes are generated from the
Conventional Commit subjects since the previous tag, grouped into breaking
changes, features, bug fixes and other changes. They go into three places: a new
section on top of `CHANGELOG.md`, a short list in `manifest.json` (that is what
Jellyfin's plugin catalogue shows), and `release-notes-v<version>.md`, which the
script prints. The script itself never pushes; pushing the tag is what publishes
the release.
## Testing
One entry point runs everything that gates a push:
```bash
./run-tests.sh
```
That is the shell tests, a Release build, the unit tests (`*.Tests.csproj`) and
the Playwright suite in `e2e/`, which starts a real Jellyfin in Docker against a
fake `media.ccc.de`. The branch guard runs it on every `git push`.
Five suites stay out of it because they need an emulator, a network clone, a
published release or the live media.ccc.de, and are opt-in:
| Suite | How | What it covers |
|---|---|---|
| CCC API contract | `CCC_CONTRACT=1 ./run-tests.sh` | the **real** api.media.ccc.de still has the fields the plugin reads ([`Contract/`](Jellyfin.Plugin.Chaosflix.Tests/Contract)) |
| Android phone app | `ANDROID_E2E=1 ./run-tests.sh` | real ExoPlayer on an emulator ([`e2e/android`](e2e/android)) |
| Cast receiver | `RECEIVER_E2E=1 ./run-tests.sh` | the real jellyfin-chromecast bundle in a browser |
| Android TV | `e2e/android/tv-repro.sh` | jellyfin-androidtv on a TV emulator, driven by hand |
| Published plugin | `e2e/install/install-from-manifest.sh` | a fresh Jellyfin installing the **released** plugin from `manifest.json`, checksum included |
The e2e stack is configurable: `JELLYFIN_TAG` (defaults to `12.1`, the version
`targetAbi` promises), `JELLYFIN_PORT`,
`COMPOSE_PROJECT_NAME` and `E2E_FIXTURE_SECONDS`. Unless you set the middle two
yourself, `run-tests.sh` picks a compose project and a host port from the path of
the checkout it runs in, so several worktrees can run the suite side by side.
## Continuous Integration
| Workflow | When | What |
|---|---|---|
| `ci.yml` | every PR and push to `main` | `./run-tests.sh` on a GitHub-hosted runner |
| `release.yml` | a `v*` tag | build, publish the release with its ZIP, commit the checksum |
| `android-e2e.yml` | nightly, or on demand | the Android emulator suite |
| `install-e2e.yml` | after a release, and nightly | install the published plugin into the newest Jellyfin |
| `contract.yml` | nightly, or on demand | the contract tests against the real media.ccc.de API |
The runner deliberately has no `ffmpeg`, so the fixture build exercises its
container fallback — that path was broken for as long as nothing ran it.
## Known Limitations
- Jellyfin stores a channel item under exactly one parent, so a talk listed in
several folders needs one id per folder and becomes several library items.
Watch state is mirrored between them, but the copies show up as duplicates in
"Recently added" ([#54](https://github.com/praetorianer777/chaosflix-jellyfin/issues/54)).
- Talks cannot be downloaded for offline playback. Jellyfin offers a download
only for items backed by a file on the server — `Video.CanDownload()` returns
`IsFileProtocol`, and a channel item's path is whatever its media source says,
which here is an http url. `/Items/{id}/Download` then serves that path off
disk, so there is nothing for it to send
([#90](https://github.com/praetorianer777/chaosflix-jellyfin/issues/90)).
- Topics can be filtered but not browsed. A talk's tags are mapped to genres
and filtering by one works (`/Items?genres=Security`), but Jellyfin's genre
browse lists nothing: it builds genre entities during a library scan, which
channel items never go through. Asking Jellyfin to create them turns out not
to be enough either — with them present, `/Genres` listed three of five while
all five resolved correctly by id and by name, stable across a restart, so the
listing is unreliable independently of this plugin
([#88](https://github.com/praetorianer777/chaosflix-jellyfin/issues/88)).
- Playback runs through `/api/ChaosflixStream/proxy` with a signed url rather
than straight from the CDN: ExoPlayer cannot follow the CDN's cross-domain
redirects, so mirror failover, range requests and redirects are resolved
server-side. The signature is per server process.
## How It Works
```
Jellyfin UI → Chaosflix Channel → media.ccc.de API → CDN streaming
│
├── 🔴 Live now (only during a congress)
├── 🔥 Popular (top by views)
├── ⭐ Recommended (views × recency)
├── 📅 Year → Conference → Talks
├── 🔗 Related talks, as Jellyfin's similar items
├── Speaker/tag metadata
└── Multiple quality/language sources per talk
```
The plugin implements Jellyfin's `IChannel` interface, plus
`ILocalSimilarItemsProvider` for the related talks. All heavy lifting (watch history, playback position, user management, transcoding, SyncPlay) is handled by Jellyfin itself.
## CCC API
This plugin uses the public [media.ccc.de API](https://api.media.ccc.de):
- `/public/conferences` — list all conferences
- `/public/conferences/{id}` — conference detail with events
- `/public/events/{guid}` — event detail with recordings + related talks
- `/public/events/search?q=` — full-text search
No API key required. Content is licensed under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/).
## License
MIT

View File

@@ -0,0 +1,29 @@
[
{
"guid": "c4a05f11-4ccc-4b00-bdea-dbeef1337000",
"name": "Chaosflix",
"description": "Browse and stream Chaos Computer Club conference talks from media.ccc.de directly in Jellyfin. Includes watch history, resume, SyncPlay support, popular/recommended talks, year-based browsing, and related talk suggestions.",
"overview": "CCC Media Channel for Jellyfin",
"owner": "praetorianer777",
"category": "Channels",
"imageUrl": "https://raw.githubusercontent.com/praetorianer777/chaosflix-jellyfin/main/assets/logo.svg",
"versions": [
{
"version": "0.4.0.0",
"changelog": "Breaking changes:\n- requires Jellyfin 12.1.0 or newer (raised from 10.11.0)\nBug fixes:\n- report a raised minimum server version as breaking (#113)\n- stop Popular and Recommended reshuffling on every refresh (#111)\n- stop inventing a community rating from the view count (#108)\n- judge a conflicted rebase by the branch being rebased (#106)\n- and 3 more\nPlus 3 other changes.",
"targetAbi": "12.1.0.0",
"sourceUrl": "https://github.com/praetorianer777/chaosflix-jellyfin/releases/download/v0.4.0/chaosflix-jellyfin-v0.4.0.zip",
"checksum": "6c7ff793e07ce26c73a4f4c9430fee91",
"timestamp": "2026-09-20T17:54:07Z"
},
{
"version": "0.3.0.0",
"changelog": "Features:\n- show the plugin's state on its configuration page (#93)\nPlus 5 other changes.",
"targetAbi": "10.11.0.0",
"sourceUrl": "https://github.com/praetorianer777/chaosflix-jellyfin/releases/download/v0.3.0/chaosflix-jellyfin-v0.3.0.zip",
"checksum": "b4b7c4d4eeac9e4277957e966c311210",
"timestamp": "2026-09-19T14:42:40Z"
}
]
}
]