Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled

- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped)
- IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the
  AES-128 key is fetched server-side with the session cookie, segments stay on the CDN
- delivery/info cached per token lifetime and throttled (~2/min)
- config page: paste sails.sid + validate, extra creators, quality, search test
- scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow
- research docs, HAR analysis (HAR itself gitignored), install-pi5.sh

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-25 09:42:27 +01:00
commit 9e33a343ab
162 changed files with 52004 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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