No description
  • Rust 98.1%
  • C# 1.2%
  • Shell 0.4%
  • Dockerfile 0.1%
  • HTML 0.1%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Alexander Daichendt e832860946
All checks were successful
ci / lint (push) Successful in 2m24s
ci / unit (push) Successful in 7m10s
ci / without-plugins (push) Successful in 1m42s
ci / image (push) Successful in 6m19s
xtask package: pull base images on every build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:10:18 +02:00
.cargo xtask: generate the database's enum values from upstream C# source 2026-07-28 20:50:02 +02:00
.forgejo/workflows plugins: the runtime, on the seam the subtitle port already had 2026-09-10 11:45:21 +02:00
crates Update wasmtime to 50 and zip to 9, cargo update for advisories 2026-10-23 15:09:33 +02:00
docs bench: offer the scenarios a run was leaving on disk 2026-09-12 08:39:10 +02:00
packaging config: the library update window is the operator's 2026-09-11 21:01:41 +02:00
plugins license: GPL-2.0-only, matching upstream Jellyfin 2026-10-10 20:59:31 +02:00
reference reference: the pin moves to v12.2, and ffmpeg to v8.1.3-1 2026-10-09 15:23:21 +02:00
wit subtitles: what the review of the refresh found 2026-09-10 21:53:01 +02:00
xtask xtask package: pull base images on every build 2026-10-23 15:10:18 +02:00
.dockerignore packaging: the interface the build context was missing 2026-09-10 18:12:18 +02:00
.gitattributes rename: the project is ephyra, and Jellyfin is only what upstream is called 2026-08-17 18:36:11 +02:00
.gitignore gitignore: a plugin's build directory 2026-10-11 12:47:56 +02:00
ARCHITECTURE.md photos: the switch that turns a home-video library's pictures off 2026-09-15 23:28:45 +02:00
Cargo.lock Update wasmtime to 50 and zip to 9, cargo update for advisories 2026-10-23 15:09:33 +02:00
Cargo.toml build: dev and test builds carry line tables only, and dependencies no debug info 2026-10-11 12:21:54 +02:00
CLAUDE.md docs: CLAUDE.md points at the ephyra crate paths 2026-08-17 18:43:33 +02:00
DECISIONS.md scan: the pass tail is one function; it settles only what it read and finished, and bounds what it keeps 2026-10-11 13:36:01 +02:00
ENDPOINTS.md syncplay: the state machine, and the seventeen routes that drive it 2026-09-12 15:02:49 +02:00
LICENSE license: GPL-2.0-only, matching upstream Jellyfin 2026-10-10 20:59:31 +02:00
MILESTONES.md plugins: a saved setting takes, and the interface says 0.2 2026-09-10 19:00:34 +02:00
README.md serve: a server that has been set up is not started on a missing or empty database 2026-10-09 16:37:19 +02:00
rust-toolchain.toml build: name the toolchain this tree is formatted and linted by 2026-08-14 19:08:00 +02:00

ephyra

An API-compatible Jellyfin server in Rust, scoped to watching, libraries, and transcoding. Not a fork, not a port of the whole thing.

Compatibility is decided by diffing our responses against a real upstream server, not by reading the OpenAPI spec — see crates/ephyra-harness.

  • ENDPOINTS.md — which of upstream's endpoints this server answers
  • MILESTONES.md — what, and when
  • ARCHITECTURE.md — where the code goes, and which dependency edges are illegal
  • DECISIONS.md — why, and what was rejected
  • reference/ — upstream Jellyfin at the pinned commit, reproducibly
  • crates/ephyra-encoding/README.md — how the ffmpeg arguments are known to be right
  • crates/ephyra-bench/README.md — the load generator, and why it can be believed
  • reference/corpus/README.md — what a corpus case is, and what generates it
  • docs/install.md — installing it as a service or a container, and packaging/
  • docs/sso.md — signing in through an identity provider
  • docs/upstream.md — moving the pin, and in which order
  • docs/logging.md — what the server logs, and how to ship it
  • docs/benchmarks.md — how the two servers are compared, and what a number does not mean

Build

The toolchain is pinned in rust-toolchain.toml, which rustup reads on its own — cargo fmt and cargo clippy change what they say between releases, so the version that formats and lints this tree is named rather than assumed.

Building needs ICU4C — the library, its headers, pkg-config, and libclang for the binding generator — because a sort name is ICU's transliteration and ephyra-core calls it. That is icu and clang on Arch, libicu-dev and libclang-dev on Debian.

Run the server

cargo run -p ephyra-server

It reads config.toml from the working directory, writing one with defaults if there is none, and listens on 127.0.0.1:8096. Pass another path to use it instead:

cargo run -p ephyra-server -- /path/to/config.toml

It opens paths.database for reading and writing, creating an empty one at the baseline schema when there is nothing there — a database upstream Jellyfin starts against without migrating. Only for a server yet to be set up: where server.startup_wizard_completed is true, or left out of a hand-written file, a database that is missing or empty stops the start, as it stops upstream's. The reference instance claims 8096 too, so change server.bind before running both at once.

Beside that port it answers the probe clients broadcast to find a server, which is what puts it on a phone's list without a URL being typed. That is UDP 7359 and nothing else — clients broadcast to that port alone — so the second server on one machine logs that the port is taken and is found by nobody. Turn it off with:

[discovery]
enabled = false

It answers with an address that reaches the server from where the probe came, so the default server.bind of 127.0.0.1 is offered to clients on this machine and to nobody else. Bind 0.0.0.0 to be found from the network, and set server.published_url where a proxy is what clients actually reach.

Name libraries and it scans them at startup, filling that database itself:

[[libraries]]
name = "movies"
kind = "movies"     # or `tvshows`, or `music`
paths = ["/media/movies", "/archive/films"]

Or write none of that and take the server through its startup wizard in a browser, which is what a first start with no configuration file offers: the wizard makes the first account and the library dialog writes the table above.

The scan runs behind the listener, so the port answers before the library is read: a first run reports scanned in the log when the rows are there, and /ScheduledTasks says Running until then. A harness replay started earlier diffs a library that is still filling.

Name none and it only reads, which is what the harness runs against: point paths.database at a copy of a snapshot from reference/snapshots/.

Give each server its own copy. Both write, and the pragmas are chosen so that the other one can still open the file — but two servers on one copy is two servers disagreeing about what a user has watched.

A path in that database belongs to whoever wrote the row: upstream in a container records /media/movies/..., which is not where the file is on the host. paths.substitutions rewrites the prefix, and without it the streaming routes 404 while everything that only reads rows still answers.

[[paths.substitutions]]
from = "/media"
to = "reference/media"

Transcoding is on the processor until a backend is named:

[encoding]
hardware_acceleration = "vaapi"           # or `qsv`, `nvenc`, or `none`
vaapi_device = "/dev/dri/renderD128"      # the node VAAPI opens
hardware_decoding_codecs = ["h264", "hevc", "vc1"]

Every field of the section is here — [encoding] holds the whole of what the dashboard's transcoding page edits, and a field left out of the file is upstream's own default. The page writes the same names, and the encoder reads them again for its next transcode, so a change takes without a restart. amf, videotoolbox, rkmpp and v4l2m2m build no arguments, and are refused where they are set: at startup for the file, and as a 400 for the dashboard, which upstream would have saved.

The node is asked which driver opens it, because the answer decides both the device arguments and which filters the graph is built from. On an AMD card that answer is only ever the pipeline upstream keeps for hardware without the Vulkan DRM extensions: scaling and deinterlacing on the device, and an HDR source tonemapped through OpenCL — a round trip out to memory and back, because Mesa has no VAAPI/OpenCL interop. Slower than the libplacebo pipeline upstream would take on the same card, and not wrong. crates/ephyra-encoding/README.md says what is not built and why.

Answering so far: the handshake — /System/Ping, /System/Info and /System/Info/Public, /Branding/Configuration and /Branding/Css, /Users/AuthenticateByName, /Users/Me, /Users/Public, /Users/{userId}, /Sessions/Capabilities/Full, /DisplayPreferences/{id}, and the /socket websocket. Browsing — /UserViews (and /Users/{userId}/Views), /Items and /Items/{itemId}, /Items/Latest, /Items/Root, /UserItems/Resume, /UserItems/{itemId}/UserData, /Shows/{seriesId}/Seasons and /Episodes, /Shows/NextUp, and /Items/{itemId}/Images/{type}. Artwork — /Items/{itemId}/Images, and posting, deleting and reordering what an item carries; /UserImage, which is the picture on an account. Playback — /Items/{itemId}/PlaybackInfo, /LiveStreams/Open and /LiveStreams/Close, /Videos/{itemId}/stream[.container], /Audio/{itemId}/stream[.container] and /universal. Writing — the /Sessions/Playing reports, which persist a resume position, and POST/DELETE on /UserPlayedItems/{id}, /UserFavoriteItems/{id}, and /UserItems/{id}/Rating. The running server — /Sessions and the commands that reach one, /Devices, /QuickConnect, /Users and the policies and passwords under it, /System/Configuration, /Library/VirtualFolders and /Library/Refresh, /ScheduledTasks, /Items/Counts, /MediaSegments/{id}, and the subtitle tracks under /Videos/{id}/{source}/Subtitles/{index}. Reading the rest of a page — /Items/{id}/Ancestors, /Similar, /ThemeMedia, /Intros, /Search/Hints, /Items/Filters and /Filters2, /Items/Suggestions, the by-name listings under /Genres, /Studios, /MusicGenres, /Artists, /Persons and /Years, the four /Localization tables, /Auth/Providers, /Library/MediaFolders, /System/Info/Storage, /Playback/BitrateTest, and /Videos/{id}/AdditionalParts. Letting a program in — /Auth/Keys, whose keys authenticate a request the way a login token does. The dashboard's log tab — /System/Logs and /System/Logs/Log. Getting back in without a password — /Users/ForgotPassword and /Users/ForgotPassword/Pin.

A route under a feature this project excludes answers the shape of there is none of this here rather than a 404 — an empty listing where upstream answers one. Everything else is 404. Paths are matched without regard to case, as upstream matches them.

The web client

cargo xtask fetch-web

Unpacks the pinned jellyfin-web into vendor/jellyfin-web, which paths.web names and the server serves at /web. The bundle is not committed; the checksum of the archive it comes out of is, in reference/pin.env. Without it the API still runs and the pages do not.

Test

cargo nextest run

The unit and route tests need nothing running. Six drift guards do need a fixture — the generated code and the measurements read a Jellyfin checkout, and the baseline and the read path read a snapshot. Provision the two and they run as part of any ordinary cargo nextest run:

cargo run -p xtask -- fetch-upstream    # the pinned checkout, into upstream/
cargo xtask reference bootstrap         # the snapshot, from pin.env alone

Without them each guard is #[ignore]d, so a bare run counts it as skipped rather than passed — a build script decides which, and provisioning a fixture is enough to be held to it. Nothing else is needed to run the suite: a checkout with neither still passes, and says how many it left out.

EPHYRA_REQUIRE_FIXTURES=1 cargo nextest run --run-ignored all

That is what a machine holding the fixtures runs. --run-ignored all takes the build script out of it, so a test whose fixture is absent runs and fails naming what it wanted rather than being skipped. EPHYRA_REQUIRE_FIXTURES fails the build instead, on the first fixture it finds missing — where they are provisioned, a fixture that is not there is a broken step and not a machine without docker.

CI is not that machine. Its runner offers containers and no host, and a fixture-gated test stands the reference instance up and talks to it on a published port — so the workflow runs cargo nextest run --workspace and the guards above are skipped there. What they cover is checked here or nowhere.

Diffing against upstream needs the reference instance running:

cargo xtask reference up
cargo run -p ephyra-harness -- replay \
    --upstream http://127.0.0.1:8096 --ours http://127.0.0.1:8097 \
    --scenario crates/ephyra-harness/scenarios/handshake.json \
    --out /tmp/run

scenarios/ holds one file per area: handshake.json for the login flow, library.json, browsing.json, and items.json for browsing, playback.json for negotiation and the stream routes, serving.json for the routes that hand a file or a playlist over, refusals.json for what each of them says no to, api-keys.json for what a program holding a key is allowed, backup.json for the backups a dashboard offers, which this server makes none of, branding.json for the branding section and the splashscreen under it, collections.json for what a create writes, tasks.json for what a scheduled task runs at, and editing.json, deletion.json, recommendations.json, playstate.json and sessions.json for everything else that writes. Those all carry the ids of the fixture they were written against; portable.json names no row and so replays against any library, which is what a run against a database copied off a real server wants. bundle.json is the odd one out: its contents are the paths the pinned web client asked for, read out of the server log while a browser was driven through every page — so it can find a route missing that nobody here thought to write down. clients.json is the odd one out twice over: its requests are the operations and the parameters Swiftfin and Findroid build, read out of the two clients' source rather than off a run — the harness README says what that covers and what it does not. smoke.json is not one of these — it captures the response ephyra-db's upstream_agreement test is written against.

A run also reports how much of itself neither server answered — a row neither has, or a failure they share — and exits 2 when that is most of it.

playstate.json changes both servers, so replay it last and restore the reference before the next browsing run. Then diff what the two of them wrote:

cargo run -p ephyra-harness -- diff-db \
    reference/var/config/data/jellyfin.db var/config/data/jellyfin.db

A scenario that deletes an item deletes the file, and reference restore does not put it back — it restores the state directory, and the library is baked into the image. api-keys.json, deletion.json and editing.json each take a file that way; reference up is what makes the media whole again, because it builds a fresh container and copies the library back out of it. The symptom is scan_probe and scan_ingest failing on a file that is suddenly not there, which reads like a code regression and is not one.

A scan being diffed against upstream's needs the prober upstream ships, named under [encoding]. jellyfin-ffmpeg takes -only_first_vframe and a distro build refuses it, so a probe by one is never asked for the file's first frame: RefFrames and Hdr10PlusPresentFlag stay null on every video stream, and an audio bitrate is the average over whatever was read instead. The server reports which it measured as first_video_frame at startup.

/Branding/Configuration is diffed by value, and the branding xtask reference provision sets is what config.toml has to name back:

[branding]
custom_css = ""
login_disclaimer = "test"
splashscreen_enabled = false

Configure ours at the container's own paths

Every id, every picture tag and every reported path is derived from the spelling a row is written in, not from the bytes it names. The reference records /fixtures/media/... for its media and /config/metadata/... for its artwork, because that is what those directories are called inside its container. A server told the host paths instead derives a different id for every row, and a whole-database diff then reports nothing but the prefix.

So config.toml declares the container's spelling and says where those directories are on this machine. [[paths.substitutions]] is the whole of it: the walk, the probe, the nfo read and every image go to the real file, and everything else — what is hashed, what is recorded, what is reported — is the declared path.

[paths]
data = "/config"
metadata = "/config/metadata"

[[paths.substitutions]]
from = "/fixtures/media"
to = "/absolute/path/to/reference/var/media"

[[paths.substitutions]]
from = "/config"
to = "/absolute/path/to/the/program/data/directory"

[[libraries]]
name = "movies"
kind = "movies"
paths = ["/fixtures/media/movies"]
extract_images = false

cache, log, transcodes and database are this server's own and nothing hashes them, so they stay host paths.

extract_images is on everywhere else, as upstream's three image extractors are — and it has to be off here. The reference instance is provisioned with every image fetcher disabled so that a scan's result is a function of the files, so a pair whose ours side leaves it on grows a poster, a row and a blur hash for every video the reference has none for.

Measured that way against the reference at the pin: 74 of 74 scanned rows carry upstream's id, and every one of the 40 pictures both servers hold agrees on its path and its hash. The five rows upstream has and we do not are the ones a request rather than a scan creates. Configured the other way, six ignore-list entries used to be needed and are now gone — see the harness README.

The same run fills a fallback font directory inside the reference's own configuration directory and points the reference at it. Both servers read that one directory, so name it here too, under the container's spelling:

[encoding]
fallback_font_path = "/config/fallback-fonts"

Set server.id to the reference instance's before diffing. Clients branch on it, so it is not on the ignore list, and two servers that disagree about it disagree on every response carrying it:

curl -s http://127.0.0.1:8096/System/Info/Public | jq -r .Id

Benchmark

Both servers, natively, on one host, against the same fixture:

cargo xtask reference publish                  # upstream on the host, from the pin
cargo xtask bench --fixture /path/to/anchor

docs/benchmarks.md says what a result means and — at greater length — what it does not. The short version: a number here is about one host, one snapshot, and one request set both servers answered identically, and the differential harness outranks every one of them.

A run given no anchor falls back to the default snapshot, which returns the whole table for every query. It proves the rig runs and reports nothing.

cargo xtask reference restore <snapshot-or-path> puts a fixture back where the reference instance reads it, which is what makes a second replay comparable to the first.