The default plugin catalog for Phlix.
plugins.json lists the official Phlix plugins and where to install them from.
The Phlix admin Plugins section loads this catalog by default — at a pinned
catalog release tag, not at master (see
Publishing a catalog release;
a merge to master is not live for operators until a tag is cut and the server's
pin follows it) — so you can browse and install plugins in one click, or add
another catalog URL, or paste a single plugin repo URL directly.
The catalog currently carries 21 plugin names / 42 entries — a name normally
appears twice (a semver release entry plus a "dev" twin, see the version
field below; the dev twins are omitted from the table). This table mirrors the
release entries of plugins.json at this commit — a machine-derived
view (one row per name: its release entry, or its dev entry as — (dev-only)
when the release was lawfully skipped), and a pytest tripwire —
TestReadmeTableMirrorCatalog in tests/test_catalog.py —
regenerates every row below and the count line above straight from the catalog, so
the suite reddens whenever README drifts from plugins.json (the updater rewrites the
catalog on a 6-hour bot cadence and never touches this file). plugins.json stays
the source of truth, and remember master is not operator-live until a catalog
release tag is cut
(Publishing a catalog release).
| Plugin | Type | Release | Min Server | Verified | Repo |
|---|---|---|---|---|---|
| AniDB | metadata-provider |
0.4.1 | ≥ 1.2.0 | true | phlix-plugin-anidb |
| AniList | metadata-provider |
0.4.0 | ≥ 1.2.0 | true | phlix-plugin-anilist |
| Arctic Jade Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-arctic-jade-theme |
| Emerald Abyss Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-emerald-abyss-theme |
| Example | metadata-provider |
0.1.2 | ≥ 0.10.0 | false | phlix-plugin-example |
| Jade Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-jade-theme |
| Last.fm | scrobbler |
1.2.0 | ≥ 1.2.0 | true | phlix-plugin-lastfm |
| MusicBrainz | metadata-provider |
0.4.0 | ≥ 1.2.0 | true | phlix-plugin-musicbrainz |
| MyAnimeList | metadata-provider |
0.3.0 | ≥ 1.2.0 | true | phlix-plugin-myanimelist |
| Mystic Jade Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-mystic-jade-theme |
| Obsidian Flame Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-obsidian-flame-theme |
| OMDb | metadata-provider |
0.3.0 | ≥ 1.2.0 | true | phlix-plugin-omdb |
| OpenSubtitles | subtitle-provider |
0.6.0 | ≥ 1.2.0 | true | phlix-plugin-opensubtitles |
| Phantom Mint | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-phantom-mint |
| Phantom Mint Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-phantom-mint-theme |
| Phantom Violet Theme | ui-theme |
1.0.1 | ≥ 0.44.0 | false | phlix-plugin-phantom-violet-theme |
| Sample Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-sample-theme |
| Spectral Dusk Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-spectral-dusk-theme |
| Trakt | scrobbler |
1.5.0 | ≥ 1.2.0 | true | phlix-plugin-trakt |
| Void Bloom Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-void-bloom-theme |
| Volcanic Ember Theme | ui-theme |
1.0.0 | ≥ 0.44.0 | false | phlix-plugin-volcanic-ember-theme |
Phantom Mint duplicate: the catalog carries two Phantom Mint
ui-themenames. Owner ruling 2026-11-12: the barephlix-plugin-phantom-mintis retired — installphlix-plugin-phantom-mint-theme(the kept, canonical repo). The bare name stays inplugins.json(and so in this machine-derived table) while its repo is still discovered (the updater regenerates discovered entries and cannot carry this ruling); once the repo is archived or loses itsphlix-plugintopic the updater keeps the entries unchanged (see Admission contract), so they must then be hand-removed — see Hand-repinning.
The document shape is described by plugins.schema.json
(JSON Schema, Draft 2020-12) and validated in CI by
.github/workflows/validate-catalog.yml,
which also asserts entries are in canonical order (sorted by name, release before
the dev entry within a name) with unique (name, version) pairs, and that each
pinned plugin's plugin.json satisfies the phlix-shared manifest schema (the
same contract the server enforces at install time — fetched at a pinned tag
and sha256-digest-verified, never a floating branch) — plus a pytest job that
runs the catalog test suite in tests/test_catalog.py, including tripwires that
pin the CI workflows' own trust laws. The structural half of that gate lives in
scripts/catalog-gate.jq: the single-source filter the
updater ALSO re-runs on its computed file before every bot push. schemaVersion
is now 2:
every entry pins an exact commit (ref) and the sha256 of the codeload
tarball for that commit (artifactSha256), so the Phlix server can verify the
bytes it downloads before installing. Installs always target that one immutable
commit, never a moving branch — including a dev entry: its commit is the
default branch's HEAD as re-captured by the updater at the last refresh, so the
dev channel follows the branch by re-pinning forward, not by asking installers
to resolve a branch name.
Every value below is a deliberately synthetic placeholder — the ref is 40 as
and the artifactSha256 is 64 0s, the same convention the simulation section
below feeds its fixtures with printf — so the illustrative shape can never be
mistaken for (or copy-pasted as) a live pin.
{
"schemaVersion": 2,
"name": "Phlix Official Plugins",
"plugins": [
{
"name": "phlix-plugin-synthetic",
"title": "Synthetic",
"type": "metadata-provider",
"summary": "Illustrative entry — shows the JSON shape only.",
"description": "Not a catalog member: the pin values are placeholders.",
"repo": "https://github.com/detain/phlix-plugin-synthetic",
"ref": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"artifactSha256": "0000000000000000000000000000000000000000000000000000000000000000",
"version": "1.0.0",
"minServerVersion": "1.2.0",
"verified": false,
"deprecated": false,
"yanked": false,
"tags": ["example", "shape"]
}
]
}Catalog top-level fields (per plugins.schema.json):
| Field | Required | Meaning |
|---|---|---|
schemaVersion |
yes | Catalog format version — currently 2. |
name |
yes | Human-readable catalog name ("Phlix Official Plugins"). |
plugins |
yes | The entries array below; at least one entry. |
description |
no | Optional long description of the catalog itself. |
homepage |
no | Optional catalog homepage URL. |
Per-entry fields (each object in plugins):
| Field | Required | Meaning |
|---|---|---|
name |
yes | The plugin's manifest name (^phlix-plugin-[a-z0-9][a-z0-9-]*$). |
title |
yes | Display name. |
type |
yes | Plugin category — the full schema enum is metadata-provider, scrobbler, subtitle-provider, notifier, player, integration, ui-theme. (ui-theme — a plugin registering a Phlix\Theming\ThemeSourceInterface entry class — is the catalog's largest category by name count, 12 of the 21 current names.) |
repo |
yes | Human repo URL the plugin lives at (github.com/detain/phlix-plugin-*). |
ref |
yes | 40-char lowercase commit sha the entry is pinned to (not a branch/tag). |
artifactSha256 |
yes | Bare 64-hex sha256 of …/archive/<ref>.tar.gz (no sha256: prefix). |
version |
yes | Semver at the pinned ref (from its plugin.json), or the literal "dev" for a plugin's dev-channel entry (HEAD of the default branch — re-pinned every catalog refresh, always verified: false). A plugin normally appears as a release entry plus, once the updater has seen it, a dev entry with the same name — at most two entries per name. The pairing can lawfully degrade to dev-only: a repo with no usable semver tag, or whose latest tag fails the install-contract gate (invalid/missing plugin.json at the tag), is kept as a dev entry while its release entry is skipped (the updater logs SKIP <name>: release entry skipped … keeping dev entry only). |
minServerVersion |
yes | Minimum Phlix server version the plugin declares (from its phlix_min_server_version at the pinned ref) — required since M-PHLIX-PLUGINS-13: an entry whose plugin cannot declare it predates the install contract and does not belong in the official catalog (a relaxed fork edits its own schema copy — that is the escape hatch). Catalog metadata only: the current server does not include this field in its catalog wire shape and does not pre-gate the admin UI with it — it enforces compatibility at install time, after download, from the plugin's own manifest (PluginLoader::satisfiesServerVersion). |
maxServerVersion |
no | Maximum Phlix server version supported (exclusive upper bound). Same metadata-only status as minServerVersion. |
verified |
no | Catalog maintainer's attestation: whether a human reviewed this entry. The current server does not read this boolean — the default-deny install gate is the pin itself: the server verifies downloads against ref + artifactSha256 (it recomputes the wire verified from pin presence, CatalogEntry::verified(), and refuses only an incomplete pin; operator override PHLIX_PLUGINS_ALLOW_UNVERIFIED=1). That is why dev-channel entries — always false — are still installable: they carry complete fresh pins. Auto-discovered entries start false. The updater RESETS a carried true to false as a re-attestation event whenever an auto pin move is not a fast-forward of the previously pinned commit, or a tag is recreated (same version, different commit). |
deprecated |
no | Whether the catalog maintainer marks this entry deprecated. Catalog metadata only: the current server does not parse this field — it neither warns operators nor changes installability; author-side vocabulary reserved for a future server-side deprecation surface. |
yanked |
no | Whether this version has been withdrawn by the catalog maintainer. Catalog metadata only: the current server does not parse this field and does not hide entries from the listing — same reserved-vocabulary status as deprecated. |
deprecationMessage |
no | Human-readable message intended for deprecated/yanked entries. Catalog metadata only: no current server or admin-UI surface reads it (see the deprecated / yanked rows). |
summary / description |
no | Short / long description. |
author |
no | Catalog-declared author/owner. |
tags |
no | Bounded tags (lowercase-alnum [._-] charset, max 20 per entry — M-PHLIX-PLUGINS-12); the updater unions repo topics (minus a dynamic majority-family denylist and an owner-curated static floor such as media-server/plex/composer-package) with curated tags (minus the static floor only). |
Manifest-side note (signature) — recorded triage (LW-039). The signature key in each
plugin's own plugin.json — the manifest validate-catalog.yml's blocking manifest job checks
against the phlix-shared contract — is optional in the
phlix-shared manifest contract: either a sha256:<64-hex> string or null, never in the required
set, and the server's SignatureVerifier treats a missing key and a null value identically
(accepted as unsigned, with a warning log). The official cohort's manifest shape is split at the
pins this catalog carries (re-counted 2026-11-12 across all 21 names, release and dev refs alike —
each name's two refs agree): seventeen names carry "signature": null — phlix-plugin-anidb,
phlix-plugin-myanimelist, phlix-plugin-omdb, phlix-plugin-opensubtitles,
phlix-plugin-example (the copy-me template new plugins start from) and all twelve ui-theme
plugins — while four carry no key at all (phlix-plugin-trakt, phlix-plugin-lastfm,
phlix-plugin-anilist, phlix-plugin-musicbrainz). Both shapes validate
and install identically, so this is cosmetic drift, not a defect — recorded here as deliberately
unnormalized pending the owner signature-policy ruling (estate reel LW-098), which will decide
whether to normalize the cohort or retire the copy-me null from the example.
The official catalog is the trust root: any change to a ref is a reviewable
git diff, so a malicious silent rewrite of an install target is visible in
history. Operators adding their own catalog SHOULD pin ref + artifactSha256
or the server will refuse the install in default-deny mode.
One consequence of GitHub's event model worth knowing (and the reason the
updater carries its own gate): commits pushed by the updater's GITHUB_TOKEN
do not re-trigger workflows — GitHub skips workflow starts for bot pushes
made with that token. The bot's own pushes therefore pass only the structural
gate (scripts/catalog-gate.jq, re-run inside the updater as a pre-push step:
valid input, canonical order, pair uniqueness, schema-shape mirror,
dev-never-verified) at push time. validate-catalog.yml reaches bot-era content
by its other triggers: its three data-trust legs — ajv's full schema check, the
pin-drift re-hash and the blocking manifest-contract gate — run on every HUMAN
push and PR and on a daily schedule (47 6 * * * UTC, clear of the
updater's 06:00 run window), which re-attests master as of its checkout with
no human push needed; the pytest suite runs on human pushes and same-repo PRs
only (it executes repo Python — not on schedule, not on fork PRs). So a bot-era
regression reaches a red gate at the next daily attestation, or sooner at the
next human push (e.g. the review push of a bot commit). Trust-boundary decisions the structural
gate cannot make (lineage of a carried verified, org ownership of a fetch
target) are enforced inside the updater itself — see its workflow header.
Despite the file name, the scheduled workflow
(.github/workflows/update-checksums.yml)
is a catalog generator, not a checksum fixer: it rebuilds every discovered
plugin's entry from the plugin repo's current state, then pushes when anything
moved.
- Admission contract: discovery admits a repo iff it is in the
detainorg, carries thephlix-plugintopic, and is not archived (the Phase A topic search keeps onlyarchived == false; Phase B re-confirms the repo is stilldetain-owned at that name). There is no allow-list step — the topic IS the opt-in. Before writing an entry the updater gates only the pin-time subset of the manifest contract: a parseableplugin.jsonwhosename(if present; the tagged re-check requires it) equals the repo name and matches the plugin-name pattern, atypethe catalog enum can represent, and a semverphlix_min_server_version(the release leg re-checks name / min-server / semver version on the TAGGED manifest and degrades to dev-only on failure). Full phlix-shared manifest-schema conformance is not checked by the updater — it is the downstream blocking manifest job invalidate-catalog.yml, on human pushes/PRs and the daily schedule (see the trust-root note above). A repo that is archived or loses its topic drops out of discovery: its existing entries are kept unchanged (named every run in aWARN … absent from discoveryline), not removed — see Hand-repinning below. - Per discovered plugin: a
releaseentry at the latest semver tag (version read from that tag'splugin.json,ref= the tag commit,artifactSha256= sha256 of the codeloadarchive/<ref>.tar.gzbytes), plus adeventry pinned to the default-branch HEAD (versionliteral"dev", alwaysverified: false). Release entries whose version no longer matches the latest tag are removed (stale-version cleanup); repo tags sync intotagsminus a dynamic majority-family denylist and the owner-curated static floor. - Curated fields survive roll-forwards:
title(three tiers: the entry's own non-empty title > a curated title carried from a same-name sibling entry, release entries preferred > derived from the repo name),summaryanddescriptioncarry forward;verifiedcarries only while the pin ADVANCES along fast-forward lineage — a recreated tag (same version, new commit) or a non-ancestor pin move resets it tofalse(re-attestation, see the workflow header). New auto-discovered entries startverified: false. - Triggers: a 6-hourly cron, a push to
master(the only branch), and manualrepository_dispatchof typeplugin-push(the payload is never read — a dispatch just means "run now"). - Shape: one PHP-first job (
shell_execofgh/curl/sha256sum— no Python there by owner law), self-gated before push viascripts/catalog-gate.jqbecause its ownGITHUB_TOKENpushes do not re-run CI (see the trust-root note above).
For plugins the updater discovers (the official set), pins are derived, not authored:
- A release entry's
ref/artifactSha256/versionfollow the plugin's semver tags. Hand-pinning a discovered release entry to an untagged commit is deleted or re-pinned by the bot on the very next run — the stale-version cleanup sees a version that doesn't match the latest tag. - A
deventry re-pins to HEAD every run; hand edits there don't survive either (that is the channel's purpose). - To move an official pin: cut the tag in the plugin repo (release), or push the
default branch and dispatch
plugin-push(dev). Do not editplugins.jsonby hand for discovered plugins.
Entries the updater does not discover — your own fork's catalog, or a plugin
whose repo lost its topic — are never touched, and there the manual recipe is
authoritative. ref and artifactSha256 must always move in lockstep —
they describe the same commit:
# 1. resolve the new commit sha
ref=$(git ls-remote https://github.com/detain/phlix-plugin-<x> HEAD | cut -f1)
# 2. recompute the tarball digest for THAT commit
sha=$(curl -sL https://github.com/detain/phlix-plugin-<x>/archive/$ref.tar.gz | sha256sum | cut -d' ' -f1)
# 3. read the plugin's semver at that ref (from its plugin.json) and update
# "ref", "artifactSha256" and "version" together in plugins.jsonCompute artifactSha256 only from the archive-form tarball shown in step 2 —
https://github.com/<owner>/<repo>/archive/<ref>.tar.gz. GitHub's API tarball
endpoint (https://api.github.com/repos/<owner>/<repo>/tarball/<ref>) serves
different bytes for the same commit, so its digest never matches a
codeload-based pin and bumping with it poisons the entry (this exact mix-up
poisoned every pin in the catalog once; CI's pin-drift job hashes the
archive form).
Never bump ref without recomputing artifactSha256 (and vice versa); CI
validates shape, sort order and every pinned plugin's plugin.json manifest,
the blocking pin-drift job re-hashes each pinned tarball on pushes, and the
server rejects an install whose downloaded bytes do not hash to the recorded
artifactSha256.
Server-side Phlix does not track this repo's master. The stable channel
resolves the official catalog at an immutable release tag pinned in server
code — CatalogSourceResolver::OFFICIAL_PINNED_REF in
phlix-server (operators can override
that ref with the PHLIX_PLUGINS_CATALOG_REF environment variable). Bot commits
and reviewed merges to master therefore change what reviewers see, not what
installs happen — do not treat a "review those commits" style push as a
release action.
To publish catalog changes to operators:
- Land them on
master(a human push, or the updater's refreshed push). - Cut a catalog release tag here (semver, e.g.
v2.5.0) — release waves are maintainer-scheduled; the automation never tags. - Bump
OFFICIAL_PINNED_REFin phlix-server to the new tag (or have operators setPHLIX_PLUGINS_CATALOG_REF).
# ajv-cli needs ajv-formats for the "uri" format keyword — exact versions, the
# same pins the CI schema job uses (floating @5/@2 re-resolve to upstream-next):
npx --yes -p ajv-cli@5.0.0 -p ajv-formats@3.0.1 ajv validate \
--spec=draft2020 -c ajv-formats -s plugins.schema.json -d plugins.json
# or with check-jsonschema:
# python3 -m pip install check-jsonschema
# check-jsonschema --schemafile plugins.schema.json plugins.json
# The canonical-order / (name,version) dedup / shape-mirror gate (single source
# of truth — the same script CI runs, and the updater runs before pushing):
jq -e -r -f scripts/catalog-gate.jq plugins.jsonThe same assertions (schema conformance, canonical order, (name, version)
uniqueness, ref/artifactSha256 lockstep and format, dev entries never
verified) — plus tripwires pinning the CI workflows' own trust laws — are
covered by the pytest suite in tests/test_catalog.py
(fixtures in tests/conftest.py, config in pytest.ini):
# --require-hashes installs exactly the hash-locked closure CI installs:
python3 -m pip install --require-hashes -r requirements-test.txt
python3 -m pytest tests/ -v --tb=short --cov=. --cov-report=term-missingThe updater is ~1,100 lines of PHP living inside a YAML heredoc — deliberate (the
PHP-first single-job law keeps it next to its trigger), but it is still fully
runnable off GitHub Actions against fixtures. Extract the script, then drive
it with the four PHLIX_* simulation seams the script reads, plus
GITHUB_WORKSPACE (not a sim seam — the Actions variable naming where the
rewrite is written; GITHUB_OUTPUT / GITHUB_STEP_SUMMARY are optional and
simply skipped when unset):
| Env var | Effect |
|---|---|
PHLIX_DISCOVERY_JSON |
JSON array of repo names — replaces the Phase A gh api topic search wholesale, so the org / topic / not-archived filter does not apply (each name must still pass the plugin-name guard). CI never sets it; unset or blank → discovery runs the live gh api search (needs an authenticated gh) |
PHLIX_SKIP_FETCH |
any non-empty value — Phase B reads fixture state files instead of gh/curl |
PHLIX_STATE_DIR |
where the Phase B state files AND the pristine plugins.json are read from (default /tmp — exactly what CI uses) |
PHLIX_PHASE_B_ONLY |
any non-empty value — stop after Phase B, so Phase C merges can be re-run against the same captured state |
GITHUB_WORKSPACE |
not a sim seam: the directory the rewritten plugins.json is written to (unset or blank → ., the current directory). In sim always point it at the scratch dir — run from the repo root without it, a changed run rewrites the checkout's plugins.json |
scratch=$(mktemp -d /tmp/opencode/phlix-plugins-sim.XXXXXX)
# 1. extract the heredoc PHP (body indented 10 spaces inside the run step)
sed -n "/php << 'PHPEOF'/,/^ PHPEOF\$/p" .github/workflows/update-checksums.yml \
| sed '1d;$d;s/^ //' > "$scratch/updater.php"
php -l "$scratch/updater.php"
# 2. fixtures: one discovered plugin pinned at a syntactically valid HEAD
# (state-file layout documented in the heredoc; digests may be literal test
# values in sim — Phase B is skipped, nothing re-hashes them)
cp plugins.json "$scratch/plugins.json"
printf '%s\n' "$(printf 'a%.0s' $(seq 1 40))" > "$scratch/ref_phlix-plugin-anidb.txt"
printf '%s\n' "$(printf '0%.0s' $(seq 1 64))" > "$scratch/sha256_phlix-plugin-anidb.txt"
printf '%s\n' '["anime","metadata"]' > "$scratch/topics_phlix-plugin-anidb.json"
# 3. run the merge with GITHUB_WORKSPACE pointed at the SCRATCH copy — never
# the checkout (the pristine read comes from PHLIX_STATE_DIR, the write goes
# to GITHUB_WORKSPACE; both are $scratch here) — and read the rewrite back
# as a diff
PHLIX_STATE_DIR="$scratch" PHLIX_SKIP_FETCH=1 PHLIX_DISCOVERY_JSON='["phlix-plugin-anidb"]' \
GITHUB_WORKSPACE="$scratch" php "$scratch/updater.php"
diff "$scratch/plugins.json" plugins.jsonA manifest_<name>.json fixture (the plugin's plugin.json at the ref) makes
the run behave like production for the title/type/version/minServerVersion
paths; without one, the merge SKIPs the repo at the manifest gate, which is
itself a useful thing to see locally.
Fork this repo (or create your own), edit plugins.json (keeping it valid
against plugins.schema.json — pin ref + artifactSha256 + version per
entry), and add its URL as a plugin source in the Phlix admin Plugins
section.
On a fork, delete or disable .github/workflows/update-checksums.yml before
enabling Actions. The updater is hardcoded to the detain org end to end
(org-scoped discovery, detain tarball fetches, repo-field construction): left
enabled it would rewrite your plugins.json into a copy of the official catalog
— it now aborts with an error step instead of doing that silently, but the
correct fork posture is removal. The rest of the toolchain is fork-safe with one
edit: relax the ^https://github\.com/detain/ repo pattern in
plugins.schema.json (and the same mirror in scripts/catalog-gate.jq) to your
own org.
MIT — see LICENSE.