Jellyfin plugin build recipe (single-source, standalone, multi-ABI)
Instructions for setting up (or converting) a Jellyfin plugin so it builds against the published
Jellyfin.* NuGet packages with no server checkout, single-sources its version from one file, and
publishes to a plugin catalog from a GitHub Release. This is the exact setup used by this repo; follow
it to reproduce the same behavior in another plugin.
Throughout, substitute your own values for the placeholders:
Jellyfin.Plugin.<Name>is the project/assembly name.<Guid>is the plugin GUID (generate once, keep it stable forever).<JellyfinVersion>is the published NuGet version of the host assemblies, for example10.11.11.<TargetAbi>is the four-part ABI the plugin declares, for example10.11.0.0.<framework>is the TFM that matches the ABI, for examplenet9.0.
- Standalone build. The plugin references published
Jellyfin.Controller/Jellyfin.Model/Jellyfin.CommonNuGets, not a localjellyfin/jellyfincheckout, so it builds anywhere with the .NET SDK and installs on a stock server. - One version to bump. A release edits
build.yaml'sversionand nothing else. Every other file derives the version from it. - Central package versions. Every NuGet version lives in one
Directory.Packages.props. - Multi-ABI by matrix. Each Jellyfin ABI is one CI matrix row; no per-ABI branches or flags.
- Catalog publish on Release. Publishing a GitHub Release builds the zip(s), attaches them, and
commits the catalog
manifest.json.
build.yaml # plugin manifest + the single version source
manifest.json # catalog/repository manifest (JSON array; starts as [])
Directory.Packages.props # repo-root central NuGet versions
.editorconfig # code style + analyzer severities (replaces .ruleset)
Jellyfin.Plugin.<Name>.sln # classic .sln (not .slnx), holds both projects
.github/workflows/build.yaml # build/test matrix + package + publish
.github/dependabot.yml # bumps the pinned Jellyfin.* and other NuGets
Jellyfin.Plugin.<Name>/
Jellyfin.Plugin.<Name>.csproj # the plugin; compile-only host refs
Directory.Build.props # version single-sourcing (reads ../build.yaml)
Jellyfin.Plugin.<Name>.Tests/
Jellyfin.Plugin.<Name>.Tests.csproj # xUnit; host refs copy-local
Directory.Build.props # IsPackable=false; halts inherited props
meta.json is not committed: jprm generates it inside the zip from build.yaml at package time.
---
name: "<Display Name>"
guid: "<Guid>"
version: "<TargetAbi-with-build-number>" # e.g. 10.11.0.1 ; THIS is the one value a release bumps
targetAbi: "<TargetAbi>" # e.g. 10.11.0.0
framework: "<framework>" # e.g. net9.0
overview: "One-line summary."
imageUrl: "https://raw.githubusercontent.com/<owner>/<repo>/main/assets/social.png"
description: >
Longer description for the catalog.
category: "General"
owner: "<owner>"
artifacts:
- "Jellyfin.Plugin.<Name>.dll"
changelog: >
<version> - Terse, user-facing notes for this release.<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<JellyfinVersion Condition="'$(JellyfinVersion)' == ''"><JellyfinVersion></JellyfinVersion>
</PropertyGroup>
<ItemGroup>
<!-- Host assemblies: compile-only in the plugin, copy-local in tests. Pinned, bumped by hand/dependabot. -->
<PackageVersion Include="Jellyfin.Controller" Version="$(JellyfinVersion)" />
<PackageVersion Include="Jellyfin.Model" Version="$(JellyfinVersion)" />
<PackageVersion Include="Jellyfin.Common" Version="$(JellyfinVersion)" />
</ItemGroup>
<ItemGroup>
<!-- Plugin runtime dependencies (your SDKs/libraries). -->
</ItemGroup>
<ItemGroup>
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
</ItemGroup>
<ItemGroup>
<!-- Test-only. -->
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
</ItemGroup>
</Project>$(JellyfinVersion) is the seam that lets one source tree build several ABIs: it defaults to the
current published version and the CI matrix overrides it per row with -p:JellyfinVersion=.
This reads version: out of the repo-root build.yaml so a plain local build stamps the same version a
release will. CI passes -p:Version= explicitly, so the Condition makes this a fallback only. Its
presence also stops MSBuild walking up into any parent props.
<Project>
<PropertyGroup Condition="'$(Version)' == ''">
<Version>$([System.Text.RegularExpressions.Regex]::Match($([System.IO.File]::ReadAllText('$(MSBuildThisFileDirectory)../build.yaml')), 'version:\s*"([^"]+)"').Groups.get_Item(1).Value)</Version>
</PropertyGroup>
</Project>The key move is ExcludeAssets="runtime" on the host references: they are compile-only, so the host's
own copies load at runtime and only your plugin DLL ships. FrameworkReference to
Microsoft.AspNetCore.App covers controllers/auth. Versions come from central package management, so no
Version= attributes here. Analyzer severities come from .editorconfig (see below) — there is no
<CodeAnalysisRuleSet>; modern Jellyfin retired the .ruleset in favor of .editorconfig.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework><framework></TargetFramework>
<RootNamespace>Jellyfin.Plugin.<Name></RootNamespace>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<AnalysisMode>AllEnabledByDefault</AnalysisMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<PackageReference Include="Jellyfin.Controller" ExcludeAssets="runtime" />
<PackageReference Include="Jellyfin.Model" ExcludeAssets="runtime" />
<PackageReference Include="Jellyfin.Common" ExcludeAssets="runtime" />
<PackageReference Include="StyleCop.Analyzers" PrivateAssets="all" />
<!-- Your runtime dependencies (these DO ship): e.g. <PackageReference Include="SomeSdk" /> -->
</ItemGroup>
<ItemGroup>
<!-- Embed config/web pages so the host can serve them. -->
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>Same host packages, but without ExcludeAssets, so they are copy-local and the entity types resolve
at test runtime.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework><framework></TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.<Name>\Jellyfin.Plugin.<Name>.csproj" />
<!-- Copy-local (no ExcludeAssets) so BaseItem / BaseItemKind resolve at test runtime. -->
<PackageReference Include="Jellyfin.Controller" />
<PackageReference Include="Jellyfin.Model" />
</ItemGroup>
</Project>Jellyfin.Plugin.<Name>.Tests/Directory.Build.props:
<Project>
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
</Project>Modern Jellyfin retired jellyfin.ruleset in favor of .editorconfig, and so does this recipe — drop
the <CodeAnalysisRuleSet> line and put rule severities here instead. Adapt
jellyfin/jellyfin's .editorconfig:
keep its code-style, naming, and formatting sections, then port the analyzer rule severities (the part
that used to live in the ruleset). With AnalysisMode=AllEnabledByDefault + TreatWarningsAsErrors,
the = none / = suggestion entries are what keep the build green; everything else defaults to error.
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
indent_style = space
indent_size = 4
[*.{cs,vb}]
# Naming: instance/static fields are camelCase prefixed with _ (Jellyfin convention; SA1309 disabled).
dotnet_naming_style.instance_field_style.required_prefix = _
dotnet_naming_style.static_field_style.required_prefix = _
# Analyzer severities ported from the old ruleset. Tighten to error / loosen to none|suggestion.
dotnet_diagnostic.SA1210.severity = error # usings ordered case-insensitively
dotnet_diagnostic.SA1629.severity = error # doc text ends with a period
dotnet_diagnostic.CA1305.severity = error # pass IFormatProvider (InvariantCulture)
dotnet_diagnostic.CA2007.severity = error # ConfigureAwait(false)
dotnet_diagnostic.SA1309.severity = none # allow the _ field prefix
# ... port the rest from jellyfin/jellyfin; omit rules for analyzers you don't reference.
# Tests stay lenient: relax the test-only analyzers (the test project also doesn't run StyleCop).
[*.Tests/**.cs]
dotnet_diagnostic.CA1707.severity = none # underscores in test method names
dotnet_diagnostic.CA2007.severity = none # ConfigureAwait not needed in tests
dotnet_diagnostic.SA0001.severity = noneStart it as an empty array. jprm appends each released build's entry on publish.
[]name: build
on:
push:
branches: [ main ]
paths-ignore: [ '**/*.md' ]
pull_request:
branches: [ main ]
paths-ignore: [ '**/*.md' ]
release:
types: [ published ]
workflow_dispatch:
permissions:
contents: write
jobs:
build:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
include:
# Version comes from build.yaml (the single source). An empty plugin-version uses it verbatim;
# an abi-base derives <abi-base>.<build.yaml trailing fields> so other ABIs auto-stay in lockstep
# (build.yaml 10.11.0.3 -> 12.0.0.3). A non-empty plugin-version overrides. plugin-version and
# abi-base are declared empty here (not just omitted) so the matrix context type includes them and
# the "Resolve version" step's matrix['plugin-version'] / matrix['abi-base'] do not warn.
- { abi: "10.11", framework: net9.0, dotnet: "9.0.x", jellyfin: "10.11.11", targetabi: "10.11.0.0", plugin-version: "", abi-base: "" }
# Enable 12.x by uncommenting (set jellyfin to the real published version):
# - { abi: "12.x", framework: net10.0, dotnet: "10.0.x", jellyfin: "12.0.0", plugin-version: "", abi-base: "12.0", targetabi: "12.0.0.0" }
name: build (${{ matrix.abi }})
steps:
- uses: actions/checkout@v6
# Catch the "tagged before bumping build.yaml" slip: on a release, the tag (minus the leading v)
# must equal build.yaml's version (the single source). Fails fast, before any build work.
- name: Guard - release tag matches build.yaml version
if: github.event_name == 'release'
run: |
base_ver=$(grep -m1 '^version:' build.yaml | cut -d'"' -f2)
tag="${{ github.event.release.tag_name }}"
tag="${tag#v}"
if [ "$tag" != "$base_ver" ]; then
echo "::error::Release tag v$tag does not match build.yaml version $base_ver. Bump build.yaml (and commit) before tagging."
exit 1
fi
echo "Tag matches build.yaml version: $base_ver"
- name: Setup .NET
uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ matrix.dotnet }}
- name: Resolve version
id: ver
run: |
base_ver=$(grep -m1 '^version:' build.yaml | cut -d'"' -f2)
v="${{ matrix['plugin-version'] }}"
if [ -z "$v" ]; then
abi="${{ matrix['abi-base'] }}"
if [ -z "$abi" ]; then v="$base_ver"; else v="$abi.$(echo "$base_ver" | cut -d. -f3-)"; fi
fi
echo "value=$v" >> "$GITHUB_OUTPUT"
- name: Build
run: >
dotnet build Jellyfin.Plugin.<Name>/Jellyfin.Plugin.<Name>.csproj
-c Release -p:TargetFramework=${{ matrix.framework }}
-p:JellyfinVersion=${{ matrix.jellyfin }} -p:Version=${{ steps.ver.outputs.value }}
- name: Test
run: >
dotnet test Jellyfin.Plugin.<Name>.sln
-c Release -p:TargetFramework=${{ matrix.framework }}
-p:JellyfinVersion=${{ matrix.jellyfin }} -p:Version=${{ steps.ver.outputs.value }}
- name: Package
if: github.event_name == 'release'
env:
JellyfinVersion: ${{ matrix.jellyfin }}
Version: ${{ steps.ver.outputs.value }}
run: |
pipx install jprm
sed -i \
-e "s|^version:.*|version: \"${{ steps.ver.outputs.value }}\"|" \
-e "s|^targetAbi:.*|targetAbi: \"${{ matrix.targetabi }}\"|" \
-e "s|^framework:.*|framework: \"${{ matrix.framework }}\"|" \
build.yaml
mkdir -p ./artifacts
jprm plugin build "." --output=./artifacts
- name: Upload package
if: github.event_name == 'release'
uses: actions/upload-artifact@v7
with:
name: pkg-${{ matrix.abi }}
path: artifacts/*.zip
publish:
needs: build
if: github.event_name == 'release'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
ref: main
- uses: actions/download-artifact@v8
with:
path: artifacts
merge-multiple: true
- name: Attach zips and update manifest
env:
GH_TOKEN: ${{ github.token }}
run: |
pipx install jprm
for zip in artifacts/*.zip; do
url="https://github.com/${{ github.repository }}/releases/download/${{ github.event.release.tag_name }}/$(basename "$zip")"
gh release upload "${{ github.event.release.tag_name }}" "$zip" --clobber
jprm repo add --plugin-url "$url" ./manifest.json "$zip"
done
- name: Commit manifest
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add manifest.json
git diff --cached --quiet || git commit -m "Publish manifest for ${{ github.event.release.tag_name }}"
git push- Find the non-standard references. The blocker is usually
MediaBrowser.Providers(not published as a consumable NuGet) and anyProjectReferenceinto ajellyfin/jellyfincheckout. Anything inMediaBrowser.Controller/Model/Commonis fine; those ship asJellyfin.Controller/Jellyfin.Model/Jellyfin.Common. - Replace
MediaBrowser.Providersusage. Carry your own thin client instead. For TMDB, wrap the publicTMDbLibNuGet with an injectedIMemoryCacheand a configurable API key. For other providers, hand-roll anIHttpClientFactoryclient. The goal is zero references the published NuGets do not satisfy. - Delete dead references (for example
Jellyfin.Database.Implementationsif nothing usesJellyfinDbContext). - Swap project references for package references with
ExcludeAssets="runtime"(plugin) and copy-local (tests), move versions intoDirectory.Packages.props, and add the props files above. - Verify with the build/test commands below, then wire the workflow.
dotnet build Jellyfin.Plugin.<Name>.sln
dotnet test Jellyfin.Plugin.<Name>.slnBoth stamp the version from build.yaml. To build a specific ABI locally, mirror the CI row:
dotnet build Jellyfin.Plugin.<Name>/Jellyfin.Plugin.<Name>.csproj -p:JellyfinVersion=<JellyfinVersion>- Bump
versioninbuild.yamland rewrite itschangelog(terse, user-facing). - Commit to
main. - Publish a GitHub Release tagged
v<version>. Thepublishjob builds the zip(s), attaches them, and commits themanifest.jsonupdate; the catalog picks it up from the rawmanifest.jsonURL.
Keep build.yaml's version in step with the release tag. The catalog self-routes ABI-aligned versions
(highest compatible wins), so multiple ABIs can coexist in one manifest.json.
Bump and commit build.yaml before tagging. If you tag a commit whose build.yaml still has the
old version, CI happily builds and publishes that old version under the new tag — a silent mis-release.
The release guard step (above) fails the build on this mismatch, but the fix is ordering: commit the
bump first, then create the release tag on that commit.
Uncomment (or add) a matrix row with the new framework, dotnet, published jellyfin version, and
targetabi. Set abi-base (for example "12.0") so its plugin version derives from build.yaml's
trailing build number automatically (build.yaml 10.11.0.3 becomes 12.0.0.3). No source changes if the
host APIs you use exist and are non-obsolete on that ABI; keep TargetFramework and the build.yaml
framework/targetAbi in step with whichever ABI a build targets.
- Compile-only vs copy-local. Get this backwards and you either ship the host DLLs (bloated,
conflicting) or fail to resolve entity types in tests. Plugin:
ExcludeAssets="runtime". Tests: plain. - Config collections must be settable. The host deserializes plugin config with System.Text.Json,
which silently skips get-only collection properties, so a get-only collection never receives the saved
value. Keep config collections
{ get; set; }(suppress CA2227 on them). - Analyzers run as errors.
TreatWarningsAsErrorsplusAnalysisMode=AllEnabledByDefaultplus StyleCop. Common hits: one type per file (SA1402), usings ordered case-insensitively (SA1210),ConfigureAwait(false)everywhere (CA2007),string.Create(CultureInfo.InvariantCulture, $"...")rather than raw interpolation (CA1305), public collection properties asIReadOnlyList/IReadOnlyDictionary. The test project enforces analyzers too. - Pin host versions, automate the bump.
Jellyfin.*are pinned inDirectory.Packages.propsand bumped by hand or.github/dependabot.yml; floating them across an ABI boundary breaks the build. - Keep the
.slnclassic, not.slnx; some tooling (including olderjprm/CI images) expects it. - Tag after the bump, not before. The version comes from
build.yamlat the tagged commit, not from the tag name, so tagging a not-yet-bumped commit silently ships the old version. Thereleaseguard step assertsv<tag>equalsbuild.yaml'sversionand fails fast otherwise. - Licensing. The host assemblies are GPL but referenced compile-only (not redistributed), so an MIT plugin is a defensible, common choice.