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