- Rust 98.1%
- C# 1.2%
- Shell 0.4%
- Dockerfile 0.1%
- HTML 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .cargo | ||
| .forgejo/workflows | ||
| crates | ||
| docs | ||
| packaging | ||
| plugins | ||
| reference | ||
| wit | ||
| xtask | ||
| .dockerignore | ||
| .gitattributes | ||
| .gitignore | ||
| ARCHITECTURE.md | ||
| Cargo.lock | ||
| Cargo.toml | ||
| CLAUDE.md | ||
| DECISIONS.md | ||
| ENDPOINTS.md | ||
| LICENSE | ||
| MILESTONES.md | ||
| README.md | ||
| rust-toolchain.toml | ||
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 answersMILESTONES.md— what, and whenARCHITECTURE.md— where the code goes, and which dependency edges are illegalDECISIONS.md— why, and what was rejectedreference/— upstream Jellyfin at the pinned commit, reproduciblycrates/ephyra-encoding/README.md— how the ffmpeg arguments are known to be rightcrates/ephyra-bench/README.md— the load generator, and why it can be believedreference/corpus/README.md— what a corpus case is, and what generates itdocs/install.md— installing it as a service or a container, andpackaging/docs/sso.md— signing in through an identity providerdocs/upstream.md— moving the pin, and in which orderdocs/logging.md— what the server logs, and how to ship itdocs/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.