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
fromGemfile.lock
:
bundix
- Write a short
default.nix
that uses thegemset
:
# 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.