My offline music streaming setup

I like some music alongside whatever I’m doing something else…​ sometimes. [1] While I typically go with a music streaming service like Spotify, sometimes it is done with my offline music collection.

A music streaming service like Spotify, while nice with the vast library of music you have access to, is not a replacement with the local music collection. Especially with the dangers of a cloud service where if Spotify being down will make your music collection inaccessible for the time.

It’s fun to document my setup every once in a while and the topic to tackle for this post is music management. It is especially nice for those who are into music archival or just want to have a nice digital, well-organized music collection.

For future references, here are the following components assumed for this post as well as its versions.

  • Mopidy v3.4.1

  • systemd v253

  • Beets 1.6.0 with Python 3.10.x

  • gonic v0.15.2

  • home-manager version 23.05

  • NixOS version 23.05

Dialog on acquiring music
Ezran

How do you download those into your local collection anyways? I hope you’re not just downloading them with a tool without paying for it.

foodogsquared

Says the one who bootlegs recordings borrowed from their friends…​

foodogsquared

Also, I paid for some of them. Otherwise, I just use a tool like spotDL and yt-dlp. Although sometimes I do use YouTube Music instead of Spo-

Ezran

Hey, I paid for some of those cassettes, CDs, and whathaveyou!

Also, those bootlegs are for me! Completely legal.

Mopidy

Half of the personal music streaming setup is Mopidy, a music server with a very nice ecosystem. With more of the ecosystem, you can use other extensions that adds sources such as in Spotify, YouTube, Internet Archive, and Funkwhale.

Mopidy is a music server that comes with only a command-line interface which you can see more details on mopidy(1). For a start, Mopidy has a comprehensive quickstart and it is widely available on mainstream Linux distributions such as Debian, Arch Linux, and Fedora. While installing Mopidy varies between distributions, typically installing Mopidy consists of installing Mopidy itself and additional extensions.

  • The one I recommend the most is Iris which is a Mopidy extension that adds a web-based interface for managing and playing music with your Mopidy configuration.

    A screenshot of Iris player on the albums section
    Iris music player
  • Another extension I would recommend is mopidy-mpd which adds an MPD-compatible server. With the MPD server enabled, you can then use MPD clients such as Ymuse and ncmpcpp.

  • Lastly, I would install additional extensions for other sources such as YouTube, Internet Archive, and Funkwhale.

After installing the extensions you need, it is time to configure it with a plain-text file. You have to be a bit familiar with the configuration syntax of Mopidy which uses an INI-like format. For an example, here’s my Mopidy configuration barring sensitive parts such as my Last.fm and Spotify account secrets.

~/.config/mopidy/mopidy.conf
[file]
enabled = true
media_dirs = 
  $XDG_MUSIC_DIR|Music
  ~/library/music|Library

[http]
hostname = 0.0.0.0

[internetarchive]
browse_limit = 150
collections = 
  fav-foo-dogsquared
  audio
  etree
  audio_music
  audio_foreign
enabled = true
search_limit = 150

[m3u]
base_dir = /home/foo-dogsquared/Music
default_encoding = utf-8
default_extension = .m3u8
enabled = true
playlists_dir = /home/foo-dogsquared/Music/playlists

Since INI format doesn’t have a clear way of specifying list, Mopidy has a format tweak where lists look like the following.

key =
  value1
  value2
  value3

This is seen with the value of file.media_dirs which is a list of directories with an optional name separated by a pipe (i.e., |) and in internetarchive.collections which is a list of Internet Archive playlists to be included in the server.

Among other things, the above configuration does the following…​

  • Initiate the Mopidy server at localhost.

  • Adds local source from $XDG_MUSIC_DIR and other locations.

  • Interacts with the playlists (in M3U format) from $XDG_MUSIC_DIR/playlists.

  • Adds Internet Archive playlists.

Points of interest for Mopidy documentation

Mopidy’s documentation is fairly comprehensive. Here are some of the pages you’re more likely to go back repeatedly.

With the configuration complete, you’ll have to start the server by simply running mopidy. However, it would be preferable to activate the server at the startup. If you’re using systemd, you could create a service unit as described from systemd.service(5).

~/.config/systemd/user/mopidy.service
[Install]
WantedBy=default.target

[Service]
ExecStart=mopidy --config %E/mopidy/mopidy.conf

[Unit]
After=network.target
After=sound.target
Description=mopidy music player daemon
Documentation=https://mopidy.com/

%E/mopidy/mopidy.conf corresponds to whatever XDG_CONFIG_HOME resolves to. You can see more of these specifiers from systemd.unit(5) "Specifiers" section.

Then you could enable the service with the following command.

systemctl --user enable --now mopidy.service

Hoorah! Now you have an offline music server! While you could manage your collection in Iris, there are better tools suited for that task which we’ll cover next.

Beets

Beets is a music management system for organizing your music collection — embedding proper metadata, organizing file structure, and more. This is what I use to organize my music directory which makes it usable for other services that expect organized structure such as Mopidy and Gonic. The most notable thing with Beets is it uses metadata from MusicBrainz (at least by default).

Using Beets is very similar with using Mopidy:

Furthermore, Beets also comes with a command-line interface with a user manual at beet(1).

How you configure Beets is through a plain-text file stored at a default configuration file which you can show with the following command.

beet config --path --default

You can easily edit the configuration file with beet config --default --edit. There are a lot of tweaks and behaviors you can change but it is easier to show an example configuration like in the following listing.

~/.config/beets/config.yaml
directory: /home/foo-dogsquared/Music
fuzzy:
  prefix: '-'
ignore_hidden: true
import:
  group_albums: true
  incremental: true
  link: false
  log: beets.log
  move: true
  resume: true
library: /home/foo-dogsquared/Music/library.db
match:
  ignore_video_tracks: true
plugins:
- acousticbrainz
- chroma
- edit
- export
- fetchart
- fromfilename
- fuzzy
- mbsync
- playlist
- scrub
- smartplaylist
scrub:
  auto: true
smartplaylist:
  playlist_dir: /home/foo-dogsquared/Music/playlists
  playlists:
  - name: all.m3u8
    query: ''
  - name: released-in-$year.m3u8
    query: year:2000..2023
  relative_to: /home/foo-dogsquared/Music
ui:
  color: true

If you’re changing how the auto-tagging match works with its related options, the matches that Beets accepts change significantly. For example, if you’ve modified match.required to enforce accepting matches with year and a label, you’ll be missing out on a lot of matches since not every database entry on MusicBrainz are uniform.

Among other things in the configuration, you’ll need to explicitly specify which plugins to use (which Beets comes with an extensive list of them). Anyways, here’s what my configuration does.

  • It automatically creates playlists separated by year with the smartplaylist plugin. This is what part of my Mopidy configuration gets its playlist from. [2]

  • The import process also fetch the album art from MusicBrainz which is nice for music players. This is enabled by fetchart plugin.

  • The import process also scrubs the metadata in music files with the scrub plugin.

A few interesting Beets plugins

There’s still possible changes for my Beets configuration since it offers so much. A few points of interest for me are…​

  • Importing playlists from a Subsonic server with the subsonicplaylist plugin. This is especially nice if you have a self-hosted Subsonic-compatible server such as gonic.

  • Easily sharing my Beets library with the ipfs plugin.

  • Extracting the BPM with bpm plugin which prompts you to rhythmically confirm the BPM.

After configuring it, you can then start using Beets. The workflow of Beets is pretty simple: it is a music management system that comes with an library database (configured with library option). Beets only considers music files that are included in the library database. To get started, we have to fill the library database with some music files with the following command.

beet import ~/Downloads/music

Beets will then start to match metadata of the audio files into its sources (such as MusicBrainz and Deezer) and prompts the user what to do next.

beet import usage
beet import usage

Beets, with all of its niceties, have a lot of problems especially with the auto-tagging feature. The most notable thing being the performance which is already acknowledged from the user manual. It is pretty slow especially once you import multiple music files from multiple albums.

Another situation that performance can really hinder is importing larger albums where Beets prompts multiple times. Take note that importing music in the same collection make Beets not only prompt but also merge them which takes some more time. This means you really have to pay attention. Thankfully, Beets can resume import if the process has been interrupted. Still, auto-tagging can be a tedious experience.

foodogsquared

Just imagine the previous image but Beets only recognizes one part of the album at a time. Rinse and repeat until all tracks are in the album. But then between each time it prompts there’s an additional prompt to make you either merge the album which takes up more time.

That’s the dilemma for larger albums.

Ezran

Sounds tedious. Why don’t you just use something like MusicBrainz Picard? It is more integrated and seems to be more responsive as an auto-tagger.

foodogsquared

Yeah but I want my well-organized and completely-managed-by-Beets music library though.

Gonic

While this component is not essential if you’re the only user of the setup, it would be nice to have it distributed as a server. One of the most common way to distribute your local collection as a streaming server is with Subsonic which able to attract programs and extensions such as a Mopidy extension.

However, we’re not going to use Subsonic itself as it hasn’t been active in its development which led to forks and compatible servers such as Airsonic, Navidrome, and Funkwhale. Instead, we’re using gonic, one of the Subsonic-compatible servers and it is lightweight enough for my needs compared to the aforementioned servers. More specifically, I like that it has the following features.

  • Listenbrainz scrobbling.

  • It has internet radio support (which some Subsonic clients apparently make use of).

  • Multi-user with their own preferences, data, and whatnot.

The last one being the most important which I could then share the server with other music listeners.

Ezran

Wait! Why do you have two music servers anyways?

foodogsquared

I would like to distribute my audio for my other devices which Mopidy is not suited for. Mopidy is closer to MPD by design which is more focused on playing music on the device running the server.

foodogsquared

I could also share the server with other users for their music collection.

Ezran

Fair point, I guess.

Unlike Mopidy (or the next tool), Gonic is not as available as the other featured tools (as of 2023-05-27) so you’ll have to build the source code yourself.

Using Gonic first starts with configuring the server. There are different ways to configure it.

  • With passing arguments on the command-line interface.

  • With a plain-text file which have to be indicated with -config-path option on the command-line interface.

  • With environment variables.

You can use them all if you want to. However, I recommend sticking to one and configuring with a plain-text file as it is easier to transfer the configuration between different servers and with different service managers (if you make use of them).

Anyways, here’s the configuration file for my Gonic server for my system-wide installation.

/etc/gonic/gonic.conf
jukebox-enabled true
listen-addr 192.168.1.1:4747
scan-at-start-enabled true
scan-interval 1

music-path /srv/music
cache-path /var/lib/gonic
podcast-path /var/lib/gonic/podcasts

The above configuration meant to be started with the following command to start the server.

gonic -config-path /etc/gonic/gonic.conf

However, it is better to handle this by the service manager (in this case, systemd). The following systemd service unit is sufficient enough.

/etc/systemd/system/gonic.service
[Install]
WantedBy=default.target

[Service]
ExecStart=gonic -config-path /etc/gonic/gonic.conf

[Unit]
After=network.target
After=sound.target
Description=Gonic server
Documentation=https://github.com/sentriz/gonic

A better example of a systemd service unit can be seen in its source code.

You could also improve it by hardening the service which systemd definitely has options listed in systemd.exec(5). For a more comprehensive example of a hardened version of the systemd service, you could look into Gonic service implementation from nixpkgs.

After setting up the server, just don’t forget to immediately log in to the service with the default account and change the password. This is especially important if you’re going to deploy it for the public.

Then, I have to set up my go-to features: create a user token for Listenbrainz for them recommendations, add a list of internet radios, and subscribe to several podcasts. Finally, all I have to do is to deploy it on the public, set up the networking, and voila! My own little music streaming server suitable to be used for multiple users.

Similar to Mopidy, Gonic doesn’t organize them music folder for you. That is the job more suitable for Beets.

Overall, I find Gonic to be a very nice lightweight music streaming server. At this point, to make use of the service, you’ll have to use a Subsonic client. My client of choice is Ultrasonic but DSub is a close contender too.

Setting up in home-manager

If you’ve seen my recent writings, you would know I’m a Nix enthusiast. Fortunately, there’s a way to easily reproduce the music player setup with home-manager which is nice for user-specific configurations. In fact, home-manager does come with Nix modules to setup both Beets and Mopidy.

Here’s the equivalent home-manager configuration for my Mopidy setup with additional extensions to be installed…​

home-manager equivalent for Mopidy configuration
let
  musicDir = config.xdg.userDirs.music;
  playlistsDir = "${musicDir}/playlists";
in
{
  services.mopidy = {
    enable = true;
    extensionPackages = with pkgs; [
      mopidy-beets
      mopidy-funkwhale
      mopidy-internetarchive
      mopidy-iris
      mopidy-local
      mopidy-mpd
      mopidy-mpris
      mopidy-youtube
    ];

    settings = {
      http = {
        hostname = "0.0.0.0";
      };

      file = {
        enabled = true;
        media_dirs = [
          "$XDG_MUSIC_DIR|Music"
          "~/library/music|Library"
        ];
      };

      internetarchive = {
        enabled = true;
        browse_limit = 150;
        search_limit = 150;
        collections = [
          "fav-foo-dogsquared"
          "audio"
          "etree"
          "audio_music"
          "audio_foreign"
        ];
      };

      m3u = {
        enabled = true;
        base_dir = musicDir;
        playlists_dir = playlistsDir;
        default_encoding = "utf-8";
        default_extension = ".m3u8";
      };
    };
  };
}

…​for the Beets configuration,

home-manager equivalent for Beets configuration
let
  musicDir = config.xdg.userDirs.music;
  playlistsDir = "${musicDir}/playlists";
in
{
  # My music player setup, completely configured with Nix!
  programs.beets = {
    enable = true;
    settings = {
      library = "${musicDir}/library.db";
      plugins = [
        "acousticbrainz"
        "chroma"
        "edit"
        "export"
        "fetchart"
        "fromfilename"
        "fuzzy"
        "mbsync"
        "playlist"
        "scrub"
        "smartplaylist"
      ];
      ignore_hidden = true;
      directory = musicDir;
      ui.color = true;

      import = {
        move = true;
        link = false;
        resume = true;
        incremental = true;
        group_albums = true;
        log = "beets.log";
      };

      match.ignore_video_tracks = true;

      # Plugins configuration.
      fuzzy.prefix = "-";
      scrub.auto = true;
      smartplaylist = {
        relative_to = musicDir;
        playlist_dir = playlistsDir;
        playlists = [
          {
            name = "all.m3u8";
            query = "";
          }
          {
            name = "released-in-$year.m3u8";
            query = "year:2000..2023";
          }
        ];
      };
    };
  };
}

…​and for the Gonic service setup which you could make it accessible through a VPN setup like Wireguard or Tailscale.

home-manager setup for Gonic service
let
  musicDir = config.xdg.userDirs.music;
  playlistsDir = "${musicDir}/playlists";
in
{
  systemd.user.services.gonic = {
    Unit = {
      After = [ "network.target" "sound.target" ];
      Description = "Gonic server";
      Documentation = [ "https://github.com/sentriz/gonic" ];
    };

    Service.ExecStart = "${lib.getBin pkgs.gonic}/bin/gonic -config-path %E/gonic/gonic.conf -music-path %h/Music -cache-path %C/gonic -podcast-path %C/gonic/podcasts";
    Install.WantedBy = "default.target";
  };
}

You could create much more comprehensive offline music player with home-manager. The following listing is an example of such setup.

A more comprehensive setup for offline music management
{ config, lib, pkgs, ... }:

let
  musicDir = config.xdg.userDirs.music;
  playlistsDir = "${musicDir}/playlists";
in
{
  # My music player setup, completely configured with Nix!
  programs.beets = {
    enable = true;
    settings = {
      library = "${musicDir}/library.db";
      plugins = [
        "acousticbrainz"
        "chroma"
        "edit"
        "export"
        "fetchart"
        "fromfilename"
        "fuzzy"
        "mbsync"
        "playlist"
        "scrub"
        "smartplaylist"
      ];
      ignore_hidden = true;
      directory = musicDir;
      ui.color = true;

      import = {
        move = true;
        link = false;
        resume = true;
        incremental = true;
        group_albums = true;
        log = "beets.log";
      };

      match.ignore_video_tracks = true;

      # Plugins configuration.
      fuzzy.prefix = "-";
      scrub.auto = true;
      smartplaylist = {
        relative_to = musicDir;
        playlist_dir = playlistsDir;
        playlists = [
          {
            name = "all.m3u8";
            query = "";
          }
          {
            name = "released-in-$year.m3u8";
            query = "year:2000..2023";
          }
        ];
      };
    };
  };

  services.mopidy = {
    enable = true;
    extensionPackages = with pkgs; [
      mopidy-beets
      mopidy-funkwhale
      mopidy-internetarchive
      mopidy-iris
      mopidy-local
      mopidy-mpd
      mopidy-mpris
      mopidy-youtube
    ];

    settings = {
      http = {
        hostname = "0.0.0.0";
      };

      file = {
        enabled = true;
        media_dirs = [
          "$XDG_MUSIC_DIR|Music"
          "~/library/music|Library"
        ];
      };

      internetarchive = {
        enabled = true;
        browse_limit = 150;
        search_limit = 150;
        collections = [
          "fav-foo-dogsquared"
          "audio"
          "etree"
          "audio_music"
          "audio_foreign"
        ];
      };

      m3u = {
        enabled = true;
        base_dir = musicDir;
        playlists_dir = playlistsDir;
        default_encoding = "utf-8";
        default_extension = ".m3u8";
      };
    };
  };

  programs.ncmpcpp = {
    enable = true;
    mpdMusicDir = musicDir;
  };

  systemd.user.services.gonic = {
    Unit = {
      After = [ "network.target" "sound.target" ];
      Description = "Gonic server";
      Documentation = [ "https://github.com/sentriz/gonic" ];
    };

    Service.ExecStart = "${lib.getBin pkgs.gonic}/bin/gonic -config-path %E/gonic/gonic.conf -music-path %h/Music -cache-path %C/gonic -podcast-path %C/gonic/podcasts";
    Install.WantedBy = "default.target";
  };

  home.stateVersion = "23.05";
}

Just install or activate home-manager with the above configuration fragment and you should be able to reproduce my configuration in a snap. While unprivileged user services can be used for network-wide deployment as long as the networking is configured right, home-manager is oriented towards desktop usage. You’ll be missing out on setting up things (mainly networking) so configuring it through NixOS would be better suited for this task.

Setting up in NixOS

We could also set a music streaming server with NixOS which is more suitable for servers compared to home-manager which is oriented towards desktop usage.

Here’s how we would set up with Mopidy…​

NixOS setup for Mopidy server
let
  musicDir = "/srv/music";
  playlistsDir = "${musicDir}/playlists";
in
{
  services.mopidy = {
    enable = true;
    extensionPackages = with pkgs; [
      mopidy-iris
      mopidy-local
      mopidy-mpd
      mopidy-mpris
      mopidy-youtube
    ];

    configuration = ''
      [http]
      hostname = 172.23.0.1
      port = 6669

      [file]
      enabled = true
      media_dirs =
        ${musicDir}|Music

      [m3u]
      enabled = true
      base_dir = ${musicDir}
      playlists_dir = ${playlistsDir}
      default_encoding = utf-8
      default_extension = .m3u8
    '';
  };
}

…​and with Gonic.

NixOS setup for Gonic server
let
  musicDir = "/srv/music";
  playlistsDir = "${musicDir}/playlists";
in
{
  services.gonic = {
    enable = true;
    settings = {
      listen-addr = "172.23.0.1:4747";
      cache-path = "/var/cache/gonic";
      music-path = [ musicDir ];
      podcast-path = "/var/cache/gonic/podcasts";

      jukebox-enabled = true;

      scan-interval = 1;
      scan-at-start-enabled = true;
    };
  };
}

As with Beets, you can simply alias it and pass the path of the custom configuration file. [3] Though, the file has to have appropriate permissions to easily access it for all.

Overriding Beets with custom configuration
let
  beetsOverride = pkgs.writeScriptBin "beet" ''
    ${pkgs.beets}/bin/beet --config=${./config/beets/config.yml}
  '';
in
{
  environment.systemPackages = [ beetsOverride ];
}

Anyways, here is the complete NixOS configuration for future reference which you can activate it with nixos-rebuild.

Complete music streaming setup for NixOS
{ config, lib, pkgs, ... }:

let
  musicDir = "/srv/music";
  playlistsDir = "${musicDir}/playlists";

  beetsOverride = pkgs.writeScriptBin "beet" ''
    ${pkgs.beets}/bin/beet --config=${./config/beets/config.yml}
  '';
in
{
  environment.systemPackages = [ beetsOverride ];

  services.mopidy = {
    enable = true;
    extensionPackages = with pkgs; [
      mopidy-iris
      mopidy-local
      mopidy-mpd
      mopidy-mpris
      mopidy-youtube
    ];

    configuration = ''
      [http]
      hostname = 172.23.0.1
      port = 6669

      [file]
      enabled = true
      media_dirs =
        ${musicDir}|Music

      [m3u]
      enabled = true
      base_dir = ${musicDir}
      playlists_dir = ${playlistsDir}
      default_encoding = utf-8
      default_extension = .m3u8
    '';
  };

  services.gonic = {
    enable = true;
    settings = {
      listen-addr = "172.23.0.1:4747";
      cache-path = "/var/cache/gonic";
      music-path = [ musicDir ];
      podcast-path = "/var/cache/gonic/podcasts";

      jukebox-enabled = true;

      scan-interval = 1;
      scan-at-start-enabled = true;
    };
  };

  system.stateVersion = "23.05";
}

You could set this up within a VPS. If you’re not comfortable with setting this up for the public, you could easily require the service to be accessed through a VPN. You can even set up a domain name that is only accessed through the VPN.


1. Not always, I find music sometimes distracting.
2. Though, you can also create your own playlist as long as it doesn’t conflict with the autocreated ones.
3. You have to place the file in the appropriate location relative to the NixOS configuration repository.