I’ve been setting up a whole-audio system with snapcast. One of the services I was excited to come across was pifi-radio. It’s been archived by the owner, but it installed fine alongside mpd on a recent Debian version, and has a lovely UI:

The next challenge was wrapping it up in a nix package for easy deployment and management. I’m not a Ruby developer so I’ve not tried using it in Nix, but the instructions at https://ryantm.github.io/nixpkgs/languages-frameworks/ruby/ are pretty easy to follow:

  • Write a flake.nix with my development tools (ruby and bundix)
# flake.nix
{
  description = "A basic flake with a shell";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
  inputs.systems.url = "github:nix-systems/default";
  inputs.flake-utils = {
    url = "github:numtide/flake-utils";
    inputs.systems.follows = "systems";
  };

  outputs = { self, nixpkgs, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
        let
            pkgs = nixpkgs.legacyPackages.${system};
        in {
            devShells.default = pkgs.mkShell {
                packages = with pkgs; [ bundix ruby ];
            };
        }
    );
}
  • Run nix develop to enter a shell with the tools installed
  • Write a Gemfile:
# Gemfile
source 'https://rubygems.org'
gem 'pifi', '0.4.14'	  
  • Generate a Gemfile.lock:
bundle lock
  • Generate a gemset.nix from Gemfile.lock:
bundix
  • Write a short default.nix that uses the gemset:
# default.nix
{ bundlerApp }:
bundlerApp {
  pname = "pifi";
  gemdir = ./.;
  exes = [ "pifi" ];
}
  • Hook it into our flake.nix:
# flake.nix
{
  description = "A basic flake with a shell";
  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
  inputs.systems.url = "github:nix-systems/default";
  inputs.flake-utils = {
    url = "github:numtide/flake-utils";
    inputs.systems.follows = "systems";
  };

  outputs = { self, nixpkgs, flake-utils, ... }:
    flake-utils.lib.eachDefaultSystem (system:
        let
            pkgs = nixpkgs.legacyPackages.${system};
        in {
            packages.default = pkgs.callPackage ./default.nix { };
            devShells.default = pkgs.mkShell {
                packages = with pkgs; [ bundix ruby ];
            };
        }
    );
}
  • Run nix build, and we’re done!
nix build
./result/bin/pifi

I could be done here, and set up a systemd service file pointing to the output. However, I then need two files to configure pifi-radio: /etc/pifi/config.json and /etc/pifi/streams.json. I could just define them in my NixOS configuration, but wouldn’t it be easier to set this all up through Nix too?

For this I need a NixOS module.

https://jade.fyi/blog/flakes-arent-real/ is a great guide to structuring a repo with various modules.

Now, we can generate config.json and streams.json, but where do we put them?

One option is to instruct nixos to just put them inside /etc/pifi:

environment.etc."pifi/config.json".text = (builtins.toJSON { ... });
environment.etc."pifi/streams.json".text = (builtins.toJSON { ... });

I’m not a huge fan of that though - it works, and it’s fine, but I’d like to keep everything in the nix store as much as possible. Really the only thing in /etc should be the systemd service file, and it should point into the nix store for everything it needs. Then the only thing that can modify the service is a nix rebuild, which atomically replaces the systemd file.

Doing this for streams.json is easy, since config.json has a parameter to set the path:

environment.etc."pifi/config.json".text =
	let streams = pkgs.writeText "streams.json" (builtins.toJSON cfg.streams);
	in (builtins.toJSON {
		streams_path = streams.outPath;
		...
	});

Now, what about config.json itself?

Well, it turns out that’s a hardcoded path: https://github.com/rc2dev/pifi-radio/blob/v0.4.14/lib/pifi/lib/config_getter.rb#L7

Can we make it a parameter? I’m not familiar enough with Ruby to work out how to set up extra parameters and pass it through the various layers (all the existing parameters are just for rack, the top-level server definition), but I can at least work out how to read an environment variable:

# config_getter.rb, in a checkout of pifi-radio
    PATH = ENV['CONFIG_PATH'] || "/etc/pifi/config.json"

The repository has been archived since 2021, so we’re unlikely to get this change upstreamed - we’ll have to patch it in ourselves instead.

This is where things get more complicated.

Patching Gems

https://discourse.nixos.org/t/patching-ruby-gems-of-a-nixpkgs-bundler-app-in-an-overlay/5456 points us in the right direction.

Luckily we can modify the bundleApp definition (rather than having an overlay), so our implementation is simpler:

{ lib, bundlerApp }:
bundlerApp {
    pname = "pifi";
    gemdir = ./.;
    gemset =
        let
            gems = import ./gemset.nix;
        in
            lib.recursiveUpdate gems {
                pifi.patches = [ ./config_getter.rb.patch ];
            };
  exes = [ "pifi" ];
}

Easy enough - now, what do we put in config_getter.rb.patch?

In theory it should be as easy as going into the repo and saving the output of git diff:

cd pifi-radio/
git diff > ../config_getter.rb.patch
cat ../config_getter.rb.patch
# diff --git a/lib/pifi/lib/config_getter.rb b/lib/pifi/lib/config_getter.rb
index 0ee269b..2f43176 100644
--- a/lib/pifi/lib/config_getter.rb
+++ b/lib/pifi/lib/config_getter.rb
@@ -4,7 +4,7 @@ module PiFi
   class ConfigGetter
     include Utils
-    PATH = "/etc/pifi/config.json"
+    PATH = ENV['CONFIG_PATH'] || "/etc/pifi/config.json"
     DEFAULT_KEYS = {
       "mpd_host" => "127.0.0.1",
       "mpd_port" => "6600",

Let’s give that a try:

nix build
# warning: Git tree '/home/nixos/code/pifi' is dirty
# error: builder for '/nix/store/fa6vy8dlgnlfizzlq8dq9aryrymbzs5c-ruby3.3-pifi-0.4.14.drv' failed with exit code 1;
#        last 16 log lines:
#        > Running phase: unpackPhase
#        > Running phase: patchPhase
#        > applying patch /nix/store/63wiyayc82kcc1fjg4plf2ncasbdn04i-config_getter.rb.patch
#        > can't find file to patch at input line 5
#        > Perhaps you used the wrong -p or --strip option?
#        > The text leading up to this was:
#        > --------------------------
#        > |diff --git a/lib/pifi/lib/config_getter.rb b/lib/pifi/lib/config_getter.rb
#        > |index 0ee269b..2f43176 100644
#        > |--- a/lib/pifi/lib/config_getter.rb
#        > |+++ b/lib/pifi/lib/config_getter.rb
#        > --------------------------
#        > File to patch:
#        > Skip this patch? [y]
#        > Skipping patch.
#        > 1 out of 1 hunk ignored
#        For full logs, run 'nix log /nix/store/fa6vy8dlgnlfizzlq8dq9aryrymbzs5c-ruby3.3-pifi-0.4.14.drv'.
# error: 1 dependencies of derivation '/nix/store/m4pjcssisz7agmm34ygf2w9z4ys6k74n-pifi-0.4.14.drv' failed to build
# error: 1 dependencies of derivation '/nix/store/jqy617yqsmgbq351pyjbhv2n42nspyy9-pifi-0.4.14.drv' failed to build

Uh-oh.

https://github.com/NixOS/nixpkgs/issues/31684 is a very long thread where the user is told to what they’ve done wrong at every turn, but even as someone experienced with unix tools and nix, it’s not very user-friendly. As far as we can tell though, it looks like we’re doing all the right things.

Let’s take a closer look at the state of the file when we do the patch, by overriding the prePatch hook:

{ lib, bundlerApp, tree }:
bundlerApp {
    pname = "pifi";
    gemdir = ./.;
    gemset =
        let
            gems = import ./gemset.nix;
        in
            lib.recursiveUpdate gems {
                pifi.prePatch = "${tree}/bin/tree .";
                pifi.patches = [ ./config_getter.rb.patch ];
            };
  exes = [ "pifi" ];
}

patchPhase will run prePatch before running the rest of its work, so hopefully that gives us some useful output:

nix build
# warning: Git tree '/home/nixos/code/pifi' is dirty
# error: builder for '/nix/store/b0i9qsfxbh6faxyds79cvdjxv74xxnjf-ruby3.3-pifi-0.4.14.drv' failed with exit code 1;
#        last 18 log lines:
#        > Running phase: unpackPhase
#        > Running phase: patchPhase
#        > .
#        > `-- env-vars
#        >
#        > 1 directory, 1 file
#        > applying patch /nix/store/bwr2p1md1sx2pp5sqqcvfqlrf46f139c-config_getter.rb.patch
#        > can't find file to patch at input line 3
#        > Perhaps you used the wrong -p or --strip option?
#        > The text leading up to this was:
#        > --------------------------
#        > |--- a/lib/ruby/gems/3.3.0/gems/pifi-0.4.14/lib/pifi/lib/config_getter.rb
#        > |+++ b/lib/ruby/gems/3.3.0/gems/pifi-0.4.14/lib/pifi/lib/config_getter.rb
#        > --------------------------
#        > File to patch:
#        > Skip this patch? [y]
#        > Skipping patch.
#        > 1 out of 1 hunk ignored
#        For full logs, run 'nix log /nix/store/b0i9qsfxbh6faxyds79cvdjxv74xxnjf-ruby3.3-pifi-0.4.14.drv'.
# error: 1 dependencies of derivation '/nix/store/zjqanzd685bzbgd9ywk0fhvm55nmzhb3-pifi-0.4.14.drv' failed to build
# error: 1 dependencies of derivation '/nix/store/16hgbrfvrns3jv330xm0w24pmgqj5hr6-pifi-0.4.14.drv' failed to build

Ok, now we’re at a bit of a loss - we’re trying to apply a patch to the file, but all there is there is a single env-vars file.

What about the suggestion from https://nixos.org/manual/nixpkgs/stable/#gem-specific-configurations-and-workarounds, which uses gemConfig instead of the setup above?

{ lib, bundlerApp, defaultGemConfig }:
bundlerApp {
    pname = "pifi";
    gemdir = ./.;
    gemConfig = defaultGemConfig // {
        pifi = attrs: {
            patches = [ ./config_getter.rb.patch ];
        };
    };
    exes = [ "pifi" ];
}

No dice.

The nixpkgs manual also points us to https://github.com/NixOS/nixpkgs/blob/master/pkgs/development/ruby-modules/gem-config/default.nix - any clues there?

We can see a lot of the packages have dontBuild = false. I can’t see why that would change things, but let’s give it a try, also using our prePatch trick and forcing an error:

{ lib, bundlerApp, defaultGemConfig, tree }:
bundlerApp {
    pname = "pifi";
    gemdir = ./.;
    gemConfig = defaultGemConfig // {
        pifi = attrs: {
            dontBuild = false;
            prePatch = "${tree}/bin/tree .; exit 1"
        };
    };
    exes = [ "pifi" ];
}

And indeed, we can now see our files there, ready to be patched!

nix build
# warning: Git tree '/home/nixos/code/pifi' is dirty
# error: builder for '/nix/store/7r48ciri82wxnldf1wwcl4mkzc2qgng3-ruby3.3-pifi-0.4.14.drv' failed with exit code 1;
#        last 25 log lines:
#        >     |   |   |-- static
#        >     |   |   |   |-- css
#        >     |   |   |   |   |-- 2.1781c263.chunk.css
#        >     |   |   |   |   |-- 2.1781c263.chunk.css.map
#        >     |   |   |   |   |-- main.2ea3c026.chunk.css
#        >     |   |   |   |   `-- main.2ea3c026.chunk.css.map
#        >     |   |   |   |-- js
#        >     |   |   |   |   |-- 2.5ec5bba8.chunk.js
#        >     |   |   |   |   |-- 2.5ec5bba8.chunk.js.LICENSE.txt
#        >     |   |   |   |   |-- 2.5ec5bba8.chunk.js.map
#        >     |   |   |   |   |-- main.f8812632.chunk.js
#        >     |   |   |   |   |-- main.f8812632.chunk.js.map
#        >     |   |   |   |   |-- runtime-main.5aae5a31.js
#        >     |   |   |   |   `-- runtime-main.5aae5a31.js.map
#        >     |   |   |   `-- media
#        >     |   |   |       |-- getFetch.5e98861f.cjs
#        >     |   |   |       `-- logo.91554ce9.svg
#        >     |   |   `-- vendor
#        >     |   |       `-- bootswatch_4.4.1
#        >     |   |           |-- darkly.min.css
#        >     |   |           `-- lux.min.css
#        >     |   `-- version.rb
#        >     `-- pifi.rb
#        >
#        > 23 directories, 59 files
#        For full logs, run 'nix log /nix/store/7r48ciri82wxnldf1wwcl4mkzc2qgng3-ruby3.3-pifi-0.4.14.drv'.
# error: 1 dependencies of derivation '/nix/store/0azmjb71fdqdx6gjv6nryn665mcc858f-pifi-0.4.14.drv' failed to build
# error: 1 dependencies of derivation '/nix/store/yvcyrdvmsz9g7di19gakzbcarkwiggv4-pifi-0.4.14.drv' failed to build

Let’s try the patch:

{ lib, bundlerApp, defaultGemConfig }:
bundlerApp {
    pname = "pifi";
    gemdir = ./.;
    gemConfig = defaultGemConfig // {
        pifi = attrs: {
            dontBuild = false;
            patches = [ ./config_getter.rb.patch ];
        };
    };
    exes = [ "pifi" ];
}

And it works!

nix build

Let’s check for our patch:

cat ./result/bin/pifi  | grep GEM_HOME
# Gem.paths = { 'GEM_HOME' => "/nix/store/ahzdy5k9jrfsavvkm0q1dd1nyz427rid-pifi-0.4.14/lib/ruby/gems/3.3.0" }

cat /nix/store/ahzdy5k9jrfsavvkm0q1dd1nyz427rid-pifi-0.4.14/lib/ruby/gems/3.3.0/gems/pifi-0.4.14/lib/pifi/lib/config_getter.rb  | grep -n CONFIG_PATH
# 7:    PATH = ENV['CONFIG_PATH'] || "/etc/pifi/config.json"

Success!

So finally, ‘all’ we needed was the following:

{ lib, bundlerApp, defaultGemConfig }:
bundlerApp {
    pname = "pifi";
    gemdir = ./.;
    gemConfig = defaultGemConfig // {
        pifi = attrs: {
            dontBuild = false;
            patches = [ ./config_getter.rb.patch ];
        };
    };
    exes = [ "pifi" ];
}

If you want to follow along with the nix package, you can find it at JaimeValdemoros/pifi-radio-nix.