Playing a video meant asking the server to re-encode it, always. That
decision was made nowhere and written down nowhere, so whoever needed it
re-derived it downstream — the player worked out whether it had been handed
a playlist by looking for ".m3u8" in the URL, in two places. A viewer paid
for a transcode of a file their device could have played untouched, and the
app could not tell them which it was.
One negotiation now produces one self-describing StreamSelection — direct
play, remux or transcode; over a playlist, a plain HTTP file, or a local one
— and every renderer consumes that same answer.
Measured against the development server (Jellyfin 10.11.5), 400 items
sampled for codec mix and 40 put through a real PlaybackInfo negotiation
per profile:
Linux / WebKitGTK (h264 only, 2ch) 3/40 — 7% direct play
Android / ExoPlayer (hevc, ac3/eac3, 6ch) 34/40 — 85% direct play
The library is ~80% hevc, which is why the two diverge so hard. The payoff
is overwhelmingly Android, where 85% of plays were starting a transcode
nobody needed. Linux stays near 7% until libmpv decodes the picture — the
h264-only profile is a WebKitGTK constraint, not a JellyTau choice.
DR-219 StreamSelection: url + tagged Transport (hls/progressive/localFile)
+ PlaybackKind (directPlay/directStream/transcode) + the negotiated
rendition + this source's ladder + a needs_transcoding flag derived
in Rust so the rule is answered once. Both enums are serde-tagged
so the frontend matches a discriminant, not a substring. The paths
that never negotiate get the same shape from Rust rather than
assembling one — media_local_selection for a downloaded file,
LiveStreamInfo.transport for a live channel — so there is no second
place where a transport is decided.
DR-220 The ceiling becomes two levels: a durable device default (Settings,
persisted) and a per-playback override the in-player picker sets.
The picker had called itself a "this film, this connection" control
since it was written but wrote the process-wide default, so dropping
one awkward film to 2 Mbps silently capped every video played
afterwards for the rest of the process, with Settings still showing
the old value. The override is cleared whenever playback moves to a
new item, which stops it surviving into an autoplayed next episode.
effective_streaming_quality() is the single resolution point.
DR-221 The quality picker is filled from what this media source can offer.
Rust marks a rung exceeds_source when its ceiling is at or above the
source's own bitrate — such a rung is another way to spell Original
— and the frontend does not draw those. Original is never marked; a
source whose bitrate the server does not report marks nothing, which
keeps every rung offered.
DR-222 Direct play and direct stream are negotiated, with two client-side
overrides on top because the server's answer is right about the file
and wrong about what this app will do with it: undecodable audio
(Jellyfin 10.11.5 honours a DirectPlayProfile's container and video
codec but ignores its audio codec, so it offers direct play for an
E-AC-3 track the webview renders in silence) and a viewer-pinned
audio track the file does not default to. A direct stream is a remux
and is deliberately not counted as transcoding.
DR-223 Dropped on measurement, not deferred. A master playlist from this
server carries exactly one EXT-X-STREAM-INF: Jellyfin builds it from
the single rendition the request asked for rather than publishing a
ladder. So there is no adaptation for hls.js to be preserving and
none mpv would lose — the claim that there was, in
playback-backend-unification.md, does not hold. Recorded rather than
deleted because it is a measurement: a server that does publish a
ladder would change the answer.
DR-224 Every backend consumes the same selection. The queue item carries
the transport, so player_seek_video picks its seek strategy from the
backend's decision instead of the last stream_url.contains(".m3u8")
in the codebase. Items queued by a path that never negotiated carry
None and fall back to needs_transcoding, which is exact rather than
a guess because every transcode this app requests is HLS (DR-140).
The frontend loader decision moves to streamTransport.ts so it can be
tested: the two cases that pin it are the ones that failed against the old
implementation — a progressive stream whose URL contains ".m3u8" must not
get an HLS loader, and an HLS stream whose URL contains none must.
Also verified the URL the direct-play branch builds actually serves playable
bytes: 206, video/mp4, valid ISO-BMFF, and a mid-file range works, so
seeking a direct play works.
The spec is folded into docs/architecture/{01,02,03} and deleted, per the
rule that docs/specs holds only work that has not shipped. DR-121 leaves
read-through-media-cache.md with a pointer; that spec keeps its capture half.
Not verified: real playback on a device. Direct play changes what actually
gets played, and neither fixtures nor curl prove the WebKitGTK and ExoPlayer
paths render it.
299 lines
13 KiB
YAML
299 lines
13 KiB
YAML
name: '🏗️ Build and Test JellyTau'
|
|
|
|
on:
|
|
push:
|
|
branches:
|
|
- master
|
|
paths-ignore:
|
|
- '**/*.md'
|
|
pull_request:
|
|
branches:
|
|
- master
|
|
paths-ignore:
|
|
- '**/*.md'
|
|
workflow_dispatch:
|
|
|
|
env:
|
|
# Incremental state is never reused between CI runs -- pure disk cost.
|
|
CARGO_INCREMENTAL: 0
|
|
|
|
jobs:
|
|
test:
|
|
name: Run Tests
|
|
# A release push triggers build-release.yml on the tag, which runs this exact
|
|
# test suite itself — and on a single-slot runner the two ~1h workflows would
|
|
# otherwise serialize/contend. Skip the duplicate for chore(release) commits.
|
|
# (head_commit is absent on pull_request/workflow_dispatch; startsWith(null,…)
|
|
# is false there, so those events still run.)
|
|
if: "!startsWith(github.event.head_commit.message, 'chore(release)')"
|
|
runs-on: linux/amd64
|
|
container:
|
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Cache Rust dependencies
|
|
uses: actions/cache@v3
|
|
with:
|
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
# was cached under five separate keys, which filled the runner's 74 GB
|
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
# registry/src is omitted too: cargo re-extracts it for free from
|
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
path: |
|
|
~/.cargo/registry/index
|
|
~/.cargo/registry/cache
|
|
~/.cargo/git/db
|
|
# One shared key across every job. The old per-job keys existed to stop
|
|
# debug/release target artifacts clobbering each other; with target no
|
|
# longer cached, registry contents are target-independent, so all jobs
|
|
# want the same crates. First job to finish saves; the rest restore.
|
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-cargo-registry-
|
|
|
|
- name: Cache Node dependencies
|
|
uses: actions/cache@v3
|
|
with:
|
|
path: |
|
|
~/.bun/install/cache
|
|
node_modules
|
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-bun-
|
|
|
|
- name: Install dependencies
|
|
run: |
|
|
bun install
|
|
|
|
# Tripwire for domain-taxonomy leaks into the presentation layer (a
|
|
# multi-type includeItemTypes query defining a category in the frontend).
|
|
# See scripts/check-frontend-boundary.sh and
|
|
# docs/specs/scoped-search-boundary.md.
|
|
- name: Check frontend/backend boundary
|
|
run: bash scripts/check-frontend-boundary.sh
|
|
|
|
# The docs are the maintained source of truth for architecture and
|
|
# process, and they cross-reference each other heavily. A rename that
|
|
# misses a link turns a doc into a dead end silently. Pure shell + git —
|
|
# no tool is installed at job time.
|
|
- name: Check documentation links
|
|
run: bash scripts/check-doc-links.sh
|
|
|
|
# Formatting, linting and type-checking were all configured in this repo
|
|
# and enforced by nothing: .prettierrc described a tree where 199 files did
|
|
# not match it, eslint.config.js ran in no workflow and in no hook, and
|
|
# `bun run check` ran only in build-release.yml — i.e. a type error could
|
|
# sit on master until somebody cut a tag. These three steps are what make
|
|
# those configs load-bearing. All are project deps installed by
|
|
# `bun install`; nothing is fetched at job time.
|
|
# Cheap tripwire for a class of defect this repo kept hitting: tooling on
|
|
# a rarely-taken path. scripts/build-android.sh ran `npm install` on its
|
|
# clean-build branch -- in a bun project, ignoring bun.lock and
|
|
# re-resolving the tree, which is how the Tauri plugin crate/package
|
|
# versions drifted apart and broke a release build. It survived because
|
|
# clean builds are rare.
|
|
- name: Check build tooling
|
|
run: bash scripts/check-tooling.sh
|
|
|
|
- name: Check formatting
|
|
run: bun run format:check
|
|
|
|
# RATCHET — this number only ever goes DOWN. Same policy as MIN_THRESHOLD
|
|
# in traceability-check.yml and the coverage thresholds in
|
|
# vitest.config.ts. 159 is what the tree carried when the gate went in; the
|
|
# backlog is real findings (dead bindings, unkeyed {#each}, `any` at the
|
|
# IPC boundary) that eslint.config.js documents rule by rule, each parked
|
|
# at "warn" until its class is cleared and it can be promoted to "error".
|
|
# Lower this as you clear them. Never raise it to make a build pass.
|
|
- name: Lint
|
|
run: bun run lint -- --max-warnings=158
|
|
|
|
- name: Check TypeScript
|
|
run: |
|
|
bunx svelte-kit sync
|
|
bun run check
|
|
|
|
# Tauri refuses to build when a plugin's Rust crate and npm package are on
|
|
# different minor versions. Nothing here runs `tauri build` -- that only
|
|
# happens on a tag -- so a mismatch introduced on master stayed invisible
|
|
# until the release build, which is where it was found: v0.10.0 prep hit
|
|
# `tauri-plugin-log (v2.8.0) : @tauri-apps/plugin-log (v2.9.0)`. `cargo
|
|
# check`, clippy, the tests and svelte-check had all passed.
|
|
#
|
|
# `tauri info` performs the same comparison the bundler does, without a
|
|
# build. Grepping its output is crude, but the alternative is discovering
|
|
# this at tag time again.
|
|
- name: Check Tauri plugin versions match
|
|
run: |
|
|
set -e
|
|
if bunx tauri info 2>&1 | tee /tmp/tauri-info.txt | grep -q "version mismatched"; then
|
|
echo "::error::A Tauri plugin's Rust crate and npm package versions disagree."
|
|
echo "::error::The release build will refuse to start. Align them in"
|
|
echo "::error::src-tauri/Cargo.toml and package.json (both are pinned exactly)."
|
|
grep -A6 "version mismatched" /tmp/tauri-info.txt || true
|
|
exit 1
|
|
fi
|
|
echo "✅ Tauri plugin crate/package versions agree."
|
|
|
|
# Coverage rather than a bare `bun run test`: same suite, plus the
|
|
# thresholds in vitest.config.ts, so a large untested module or a deleted
|
|
# test fails here instead of being noticed months later.
|
|
- name: Run frontend tests
|
|
run: |
|
|
bunx svelte-kit sync
|
|
bun run test:coverage
|
|
|
|
# CLAUDE.md has required `cargo fmt` + `cargo clippy` before every commit
|
|
# for as long as the rule has existed, but nothing in CI checked either,
|
|
# so the requirement rested entirely on memory. Both components are baked
|
|
# into the builder image (Dockerfile.builder: `rustup component add
|
|
# rustfmt clippy`) — nothing is installed at job time.
|
|
- name: Check Rust formatting
|
|
run: |
|
|
cd src-tauri
|
|
cargo fmt --all -- --check
|
|
|
|
# Clippy is a hard gate. It was advisory while the tree carried a warning
|
|
# backlog; that backlog is gone (0 warnings on 1.97.1, the pinned
|
|
# toolchain), so a warning here is now new breakage rather than old noise.
|
|
#
|
|
# This only means anything because src-tauri/rust-toolchain.toml pins the
|
|
# compiler: clippy's lint set moves between releases, so an unpinned gate
|
|
# would fail on whatever the runner happened to install. The pin and this
|
|
# flag stand or fall together — if you unpin, drop this back to advisory.
|
|
- name: Run clippy
|
|
run: |
|
|
cd src-tauri
|
|
cargo clippy --all-targets -- -D warnings
|
|
|
|
- name: Run Rust tests
|
|
run: |
|
|
cd src-tauri
|
|
cargo test
|
|
cd ..
|
|
|
|
# Fast per-commit Android compile check. This does NOT build a shippable APK:
|
|
# the full signed release APK is built only on tag pushes by build-release.yml
|
|
# (which runs sync-android-sources.sh + signing). Running the full bundle here
|
|
# too would duplicate a ~15min build and, without the sync step, produced an
|
|
# unsigned APK missing our custom sources/icons/proguard rules anyway.
|
|
# `cargo check` for the Android target (~1min) catches Android-specific Rust
|
|
# breakage without linking, bundling, or signing.
|
|
android-check:
|
|
name: Android Compile Check
|
|
runs-on: linux/amd64
|
|
needs: test
|
|
container:
|
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
|
env:
|
|
ANDROID_HOME: /opt/android-sdk
|
|
ANDROID_SDK_ROOT: /opt/android-sdk
|
|
NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
ANDROID_NDK_HOME: /opt/android-sdk/ndk/27.0.11902837
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Cache Rust dependencies
|
|
uses: actions/cache@v3
|
|
with:
|
|
# Registry only -- never src-tauri/target. That directory is ~16 GB and
|
|
# was cached under five separate keys, which filled the runner's 74 GB
|
|
# disk at ~1.15 GB/day (23 GB in 20 days, measured Aug 2026).
|
|
# registry/src is omitted too: cargo re-extracts it for free from
|
|
# registry/cache (155 MB of .crate tarballs vs 1.1 GB extracted).
|
|
path: |
|
|
~/.cargo/registry/index
|
|
~/.cargo/registry/cache
|
|
~/.cargo/git/db
|
|
# One shared key across every job. The old per-job keys existed to stop
|
|
# debug/release target artifacts clobbering each other; with target no
|
|
# longer cached, registry contents are target-independent, so all jobs
|
|
# want the same crates. First job to finish saves; the rest restore.
|
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-cargo-registry-
|
|
|
|
- name: Cache Node dependencies
|
|
uses: actions/cache@v3
|
|
with:
|
|
path: |
|
|
~/.bun/install/cache
|
|
node_modules
|
|
key: ${{ runner.os }}-bun-${{ hashFiles('**/bun.lock') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-bun-
|
|
|
|
- name: Install dependencies
|
|
run: bun install
|
|
|
|
- name: Cargo check (aarch64-linux-android)
|
|
run: |
|
|
TC="$NDK_HOME/toolchains/llvm/prebuilt/linux-x86_64/bin"
|
|
export CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER="$TC/aarch64-linux-android24-clang"
|
|
export CC_aarch64_linux_android="$TC/aarch64-linux-android24-clang"
|
|
export AR_aarch64_linux_android="$TC/llvm-ar"
|
|
cd src-tauri
|
|
cargo check --target aarch64-linux-android --lib
|
|
|
|
# Supply-chain gate. Until this job existed the project had no vulnerability
|
|
# scanning of any kind: nothing checked the ~500-crate Rust graph or the JS
|
|
# dependencies against a CVE feed, and nothing checked that everything we
|
|
# redistribute is licence-compatible with shipping JellyTau under MIT.
|
|
#
|
|
# The first run of this found eight vulnerabilities and one unsoundness
|
|
# (bytes, four in rustls-webpki, time, two in quick-xml, rand) — all fixed by
|
|
# `cargo update`, none of which anybody had reason to run.
|
|
#
|
|
# Runs in parallel with android-check rather than after `test`: a dependency
|
|
# advisory has nothing to do with whether the tests pass, and finding out
|
|
# sooner is the point.
|
|
security:
|
|
name: Supply Chain
|
|
runs-on: linux/amd64
|
|
container:
|
|
image: gitea.tourolle.paris/dtourolle/jellytau-builder:2026.08
|
|
|
|
steps:
|
|
- name: Checkout repository
|
|
uses: actions/checkout@v4
|
|
|
|
- name: Cache Rust dependencies
|
|
uses: actions/cache@v3
|
|
with:
|
|
path: |
|
|
~/.cargo/registry/index
|
|
~/.cargo/registry/cache
|
|
~/.cargo/git/db
|
|
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
|
|
restore-keys: |
|
|
${{ runner.os }}-cargo-registry-
|
|
|
|
# cargo-deny is baked into the builder image. It fetches the RustSec
|
|
# advisory database at run time — that is *data*, like the crates
|
|
# `bun install` fetches, not a toolchain install, so the 🔴 rule in
|
|
# CLAUDE.md is not in play here.
|
|
#
|
|
# Config and every documented exception live in src-tauri/deny.toml.
|
|
# Vulnerabilities and unsoundness are hard failures with no override;
|
|
# unmaintained transitive crates that have no safe upgrade (Tauri's GTK3
|
|
# stack, the unic-* tables) are ignored there by ID, each with a reason.
|
|
- name: cargo-deny (advisories, licences, bans, sources)
|
|
run: |
|
|
cd src-tauri
|
|
cargo deny check
|
|
|
|
# Advisory for now, deliberately. The Rust graph was clean after one
|
|
# update pass, so gating it costs nothing; the JS graph has not been
|
|
# audited before and a first run that fails the build teaches everyone to
|
|
# ignore this job. Promote to a hard gate once the output is empty and
|
|
# stays empty — same approach that got clippy from advisory to -D warnings.
|
|
- name: bun audit (advisory)
|
|
run: |
|
|
bun install
|
|
bun audit || echo "::warning::bun audit reported findings — advisory for now, see CLAUDE.md"
|