Streaming web radios in Neovim with MPlayer, Curl and JQ.

Lcoutel
7 min readMay 28, 2024

--

As an avid NTS Radio listener, I often tune in first thing in the morning, right before opening my IDE. In classic programmer fashion, I decided that opening a Firefox tab and clicking here and there was an unacceptable amount of steps and I got to work.

The solution I came up with allows me to launch NTS in the background from Neovim and display information about the show in notification pop-ups.

If you follow these steps, you’ll be able to stream any radio, provided you can get your hands on their streams URLs. The info I’m getting is specific to NTS, but the way I get and parse the data would work with any API.

What you need

I’ve had trouble with Luarocks and I run Neovim in WSL2, which does not always play nice with audio devices, so I kept things simple. There’s no proper HTTP client or JSON parser, and a lot of manual string concatenation. But it works!

This project assumes you’re on an Unix system or WSL2, and that you can play audio from a shell. I picked Mplayer mostly because it played nice with Pulse Audio.

It requires:

  • Basic knowledge of Neovim config and Lua.
  • Mplayer.
  • Curl, to get info from NTS’s API.
  • JQ, to work with JSON Data

Launching playback

First, let’s create a new file (radio.lua, for example) in our nvim config folder and create an auto-command, because we want to be able to launch the stream with a few keystrokes.

Don’t forget to import your newly created file in init.lua with require("your_folder.radio") .

We can get radio streams URLs online, for example here: https://streamurl.link/. We’ll create a global table for the urls:

STREAM_URLS = {nts_1: "https://stream-relay-geo.ntslive.net/stream",
nts_2: "https://stream-relay-geo.ntslive.net/stream2" }

User-commands are custom commands that can be called by pressing : and entering their call-sign. They should be capitalized to avoid conflicts with default commands.

Let’s write the first one using vim.system() to run Mplayer in the background. We’ll pass stdin = true as an option, because we want to pass commands to Mplayer while it runs.

We also need to specify how many parameters the command should accept with nargs , in this case just one. We’ll retrieve it with opts.args and use it to select the stream we want to play.

STREAM_URLS = {nts_1: "https://stream-relay-geo.ntslive.net/stream",
nts_2: "https://stream-relay-geo.ntslive.net/stream2" }

vim.api.nvim_create_user_command("Mplayer", function(opts)
stream_url = STREAM_URLS[opts.args]

MPLAYER = vim.system( {"mplayer", stream_url }, { stdin = true })
vim.notify("Tuning in to NTS Radio...")
end, {nargs = 1})

We don’t want to open a Neovim tab or windows, so we’ll use vim.system()to launch the stream in the background.

The first argument passed to vim.system() is a Bash command broken into separate arguments, in this case:

mplayer https://stream-relay-geo.ntslive.net/stream

Pause and stop streaming

This is nice, but we need to be able to pause playback, or else it’ll run as long as we stay in Neovim.

To do so, we’ll write a few user-commands. We’ve defined our Mplayer process as a global variable in order to access it and pass commands through stdin and that’s exactly what we’ll do:

vim.api.nvim_create_user_command("PauseMplayer", function()
if MPLAYER then
vim.notify("Pausing/Resuming Mplayer")
MPLAYER:write("p")
end
end, {})

vim.api.nvim_create_user_command("KillMplayer", function()
if MPLAYER then
vim.notify("Shutting Mplayer down.")
MPLAYER:kill()
end
end, {})

MPLAYER has a few built-in methods, we’ll use :write() to pass input, tricking Mplayer into believing we’ve pressed “p” for “pause” on our keyboard. We’ll use kill() to ruthlessly shut-down the process.

Remaps

This is cool, but still way too many keystrokes. Let’s write some remaps in our remap.lua configuration file.

vim.keymap.set("", "<leader>nts", "<cmd>Mplayer nts_1<cr>")
vim.keymap.set("", "<leader>2nts", "<cmd>Mplayer nts_2<cr>")
vim.keymap.set("", "<leader>mp", "<cmd>PauseMplayer<cr>")
vim.keymap.set("", "<leader>kmp", "<cmd>KillMplayer<cr>")

If you’re not familiar with remaps, here’s a quick breakdown of the first one:

  • The first argument "" sets the neovim modes (insert, normal, visual…) in which the remap will work. We’ll leave it as an empty string because we want our remap to work in every mode.
  • The second argument, <leader>nts , defines the sequence of keystrokes that’ll trigger the command. “Leader” can be anything, just define one with vim.g.mapleader = "key" . I like using the space-bar (defined by a string containing a space).
  • The third argument tells Neovim what to do. I’ve settled on using the bracket notation for keystrokes, but feel free to rummage through the documentation.

The first bit, <cmd>, is like hitting the command ( : ) key. Next we have our custom user-command Mplayer followed by nts_1 , the argument that’ll be passed to the function. Finally, <cr> represents the Enter key, and executes the command.

Displaying metadata

Our stream doesn’t contain much metadata, which could have been parsed using ffprobe (https://ffmpeg.org/ffprobe.html).

Thankfully, NTS has a public, although undocumented API: https://www.nts.live/api/v2/.

But… Lua doesn’t natively handle HTTP requests or JSON parsing. I could have used libraries or plugins, but in the end it proved too much of a hassle and I used JQ and cURL.

We’ll grab the data with a simple cURL GET request, pipe it into JQ and return a text file where each line contains a single value. This is a bit crude, but doesn’t introduce dependencies.

Bash command

Let’s write our bash command first:

curl -s https://www.nts.live/api/v2/live | jq -r '.results[0].now | .embeds.details.name, .embeds.details.description, .embeds.details.location_long'
  • curl -s -> executes cURL in silent mode (no output besides response data).
  • https../live -> API endpoint.

| -> Bash operator for piping a command output into the next command

  • jq -r -> executes JQ in “raw-output” mode, to get rid of JSON formatting (double quotes).

' -> open JQ parameters

  • .results[0].now -> in the first entry of results, get everything in “now”.

| -> JQ operator for applying filters

. .embeds.details.name -> Get value of “name” in “details” in “embeds”.

' -> close JQ parameters

Try it in the terminal:

Response parsing

We need to get data for the specific channel we’re listening to, so we’ll introduce a new global parameter CHANNEL in our first user-command:

STREAM_URLS = { "https://stream-relay-geo.ntslive.net/stream", "https://stream-relay-geo.ntslive.net/stream2" }

vim.api.nvim_create_user_command("Mplayer", function(opts)
if opts.args == "nts_1" then
CHANNEL = 1
elseif opts.args == "nts_2" then
CHANNEL = 2
end

local stream_url = STREAM_URLS[CHANNEL]

vim.notify("Tuning in to NTS Radio...")

MPLAYER = vim.system({ "mplayer", stream_url }, { stdin = true })
end, { nargs = 1 })

This is not elegant, but I’ve yet to figure out how to pass an integer in a remap, so it’ll do for now. We’ll set the command to the right channel with string.format().

We’ve replaced .results[0] with .results[%.1f] . Calling string.format(str, (CHANNEL — 1)) replaces the pattern in brackets with an integer. Lua indexes from 1 but JQ doesn’t, so we’ll need to substract 1 to our CHANNEL value.

In our radio.lua file, we’ll write a new function using io.popen() in “r” mode to read the output of our command as a text file. Then, we’ll iterate over lines and simply insert each line in a table, close the file and assign each entry to another table, with proper keys this time.

function get_nts_data()
local cmd =
"curl -s https://www.nts.live/api/v2/live | jq '.results[%.1f].now | .embeds.details.name, .embeds.details.description, .start_timestamp, .end_timestamp'"
cmd = string.format(cmd, (CHANNEL -1))
local res_handle = io.popen(cmd, "r")
local res = {}

for line in res_handle:lines() do
table.insert(res, line)
end

res_handle:close()

return {
playing = res[1],
desc = res[2],
loc = res[3],
}
end

There’s nothing to make sure we’re assigning the right value to the right key so we need to keep track of what each line contains.

Let’s use this in another function.

function show_metadata()
local metadata = get_nts_data()
vim.notify("Now playing: " .. metadata.playing .. "\n" .. metadata.desc .. "\n" .. "Live from: " .. metadata.loc)
end

Not bad, but descriptions are often pretty long, so let’s write an utility to add line breaks:

local function split_string(str)
if str:len() > 65 then
for i = 60, #str do
local c = str:sub(i, i)
if c == " " then
return string.sub(str, 1, i - 1) .. "\n" .. string.sub(str, i + 1)
end
end
else
return str
end
end

I won’t go into details, but this utility checks if the string is longer than 65 characters and if yes, looks for a space starting from character 60 to replace it with a line-break.

That’s better ! Now, we just need to call our function when launching the stream:

vim.api.nvim_create_user_command("Mplayer", function(opts)
if opts.args == "nts_1" then
CHANNEL = 1
elseif opts.args == "nts_2" then
CHANNEL = 2
end

local stream_url = STREAM_URLS[CHANNEL]

vim.notify("Tuning in to NTS Radio...")

MPLAYER = vim.system({ "mplayer", stream_url }, { stdin = true })
show_metadata()
end, { nargs = 1 })

And create a new user-command to call it whenever we want to:

vim.api.nvim_create_user_command("InfosNts", function()
show_metadata()
end, {})

There’s plenty more we could do, but let’s stop here for now. Here’s what the end result looks like in my terminal:

I hope you enjoyed this and that no pesky errors found their way in my code snippets.

I might come back to this project and do some more stuff with the API or add a way to bookmark shows that I like, so stay tuned.

Support NTS Radio here: https://www.nts.live/supporters. They’re the best webradio I know and they deserve it.

--

--