Skip to content

Instantly share code, notes, and snippets.

@IDisposable
Last active June 18, 2026 20:57
Show Gist options
  • Select an option

  • Save IDisposable/31b194e3f6dc5acbb0e08009b6c800bd to your computer and use it in GitHub Desktop.

Select an option

Save IDisposable/31b194e3f6dc5acbb0e08009b6c800bd to your computer and use it in GitHub Desktop.
Jellyfin plugin build recipe (single-source, standalone, multi-ABI)

Jellyfin plugin build recipe (single-source, standalone, multi-ABI)

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 example 10.11.11.
  • <TargetAbi> is the four-part ABI the plugin declares, for example 10.11.0.0.
  • <framework> is the TFM that matches the ABI, for example net9.0.

What you get

  • Standalone build. The plugin references published Jellyfin.Controller / Jellyfin.Model / Jellyfin.Common NuGets, not a local jellyfin/jellyfin checkout, so it builds anywhere with the .NET SDK and installs on a stock server.
  • One version to bump. A release edits build.yaml's version and 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.

File layout

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.

The files

build.yaml (the single version source and the plugin manifest)

---
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.

Directory.Packages.props (repo root, central versions)

<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=.

Jellyfin.Plugin.<Name>/Directory.Build.props (version single-sourcing)

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>

Jellyfin.Plugin.<Name>/Jellyfin.Plugin.<Name>.csproj

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>

Jellyfin.Plugin.<Name>.Tests/Jellyfin.Plugin.<Name>.Tests.csproj

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>

.editorconfig (code style + analyzer severities)

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 = none

manifest.json (catalog manifest)

Start it as an empty array. jprm appends each released build's entry on publish.

[]

.github/workflows/build.yaml

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

Converting an existing (server-checkout) plugin

  1. Find the non-standard references. The blocker is usually MediaBrowser.Providers (not published as a consumable NuGet) and any ProjectReference into a jellyfin/jellyfin checkout. Anything in MediaBrowser.Controller / Model / Common is fine; those ship as Jellyfin.Controller / Jellyfin.Model / Jellyfin.Common.
  2. Replace MediaBrowser.Providers usage. Carry your own thin client instead. For TMDB, wrap the public TMDbLib NuGet with an injected IMemoryCache and a configurable API key. For other providers, hand-roll an IHttpClientFactory client. The goal is zero references the published NuGets do not satisfy.
  3. Delete dead references (for example Jellyfin.Database.Implementations if nothing uses JellyfinDbContext).
  4. Swap project references for package references with ExcludeAssets="runtime" (plugin) and copy-local (tests), move versions into Directory.Packages.props, and add the props files above.
  5. Verify with the build/test commands below, then wire the workflow.

Daily commands

dotnet build Jellyfin.Plugin.<Name>.sln
dotnet test  Jellyfin.Plugin.<Name>.sln

Both 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>

Cutting a release

  1. Bump version in build.yaml and rewrite its changelog (terse, user-facing).
  2. Commit to main.
  3. Publish a GitHub Release tagged v<version>. The publish job builds the zip(s), attaches them, and commits the manifest.json update; the catalog picks it up from the raw manifest.json URL.

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.

Adding a new ABI later

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.

Build-adjacent gotchas

  • 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. TreatWarningsAsErrors plus AnalysisMode=AllEnabledByDefault plus 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 as IReadOnlyList/ IReadOnlyDictionary. The test project enforces analyzers too.
  • Pin host versions, automate the bump. Jellyfin.* are pinned in Directory.Packages.props and bumped by hand or .github/dependabot.yml; floating them across an ABI boundary breaks the build.
  • Keep the .sln classic, not .slnx; some tooling (including older jprm/CI images) expects it.
  • Tag after the bump, not before. The version comes from build.yaml at the tagged commit, not from the tag name, so tagging a not-yet-bumped commit silently ships the old version. The release guard step asserts v<tag> equals build.yaml's version and fails fast otherwise.
  • Licensing. The host assemblies are GPL but referenced compile-only (not redistributed), so an MIT plugin is a defensible, common choice.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment