Liquidsoap goes RESTful

Martin Kirchgessner

2025-06-13 Liquidshop #5

In the next 25 minutes

  • HTTP interactions with a Liquidsoap script
  • code examples

Context: community FM

A bit of context

Liquidsoap runs Radio Campus Grenoble since 2021.

Actually, it's

cf. Liquidshop #3

The radio's core

programs = fallback(track_sensitive=false,
    live, # input.harbor
    retransmit, # input.http
    recorded_shows, # switch([playlist, ...])
    autodj,  # request.dynamic(picking in Beets)
)
radio = fallback(
    delay(780. , playlist("/home/radio/jingles")),
    programs
)

It's all fun & music

BUT

Writing the .liq is a barrier

Even with examples

Accessing the machine is not enough

Although dropping music and recorded shows in folders is fine.

Liquidsoap's telnet is fading away

  • Old, unstructured protocol
  • Commands are removed progressively

harbor.http.register[.simple] 🚀

Architecture?

  • Serve an SPA with harbor.http.static
  • Harder DB interaction
  • Easy denial of service

Architecture?

  • Usual web+DB environment
  • No message queue
  • Potential deny of service

Architecture?

  • Usual web+DB environment
  • Isolated (safer) playout
  • No message queue
  • Repeated endpoints

Chosen for the project reboot as "RadioZ"

One script to rule them all

  • Less flexibility for the end-user
  • Reliability
  • Easy updates

Let's see the scripts

Simplified excerpts from radioz.liq

New radio core

programs = fallback(track_sensitive=false,
    live, # input.harbor
    retransmit, # input.http
    carts, # request.queue(id="carts")
    autodj,  # request.queue("autodj")
)
radio = fallback(
    delay(780. , request.queue(id="jingles")),
    programs
)

Push tracks

harbor.http.register(
  port=api_port,
  method="POST",
  "/queue/:queue_name",
  queue_POST
)

def queue_POST(req, res) =
  queue_name = req.query["queue_name"]
  let json.parse (posted:{
    path: string,
    artist: string?,
    title: string?,
    radioz_sound_id: int?,
  }) = req.body()

  annotated = "annotate:pushed=\"#{time()}\"" ^
    ",radioz_sound_id=\"#{null.get(posted.radioz_sound_id)}\"" ^
    ",title=\"#{null.get(posted.title)}\"" ^
    ",artist=\"#{null.get(posted.artist)}\":" ^
    posted.path

  r = request.create(annotated)
  queues[queue_name].push(r)
  res.status_code(200)
  res.json({ok=request.id(r)})
end

Tracking played tracks

radio.on_metadata(
  fun(md) -> thread.run(fast=false, {
    post_to_log(md)
  })
)

def post_to_log(md)
  data = json()
  data.add("title", md["title"])
  data.add("artist", md["artist"])
  data.add("radioz_sound_id", md["radioz_sound_id"])
  data.add("on_air", time.string('%Y-%m-%d %H:%M:%S'))
  data.add("source", current_source_id())
  _ = post_to_radioz("/metadata_log", data)
end

How to know who's on air

current_source_id = ref("starting")

def transition_source(transitioning_from, transitioning_to)
  current_source_id := source.id(transitioning_to)
  transitioning_to
end

radio = fallback([jingles, programs],
  transitions=[transition_source, transition_source],
)

Would love to crossfade here, but #4179🙏

Push to "live" page

thread.run(every=1., delay=1., fast=false, post_to_live)

def post_to_live()
  data = json()
  data.add("source", current_source_id())
  data.add("remaining", string.float(main_output.remaining()))
  data.add("elapsed", string.float(main_output.elapsed()))
  data.add("time", time.string('%Y-%m-%dT%H:%M:%S%z'))

  fill_next(data, "next_jingle", jingles_queue)
  fill_next(data, "next_autodj", autodj_queue)
  fill_next(data, "next_cart", carts_queue)

  _ = post_to_radioz("/live", data)
end

More HTTP verbs

harbor.http.register(
  port=api_port,
  method="DELETE",
  "/live",
  live_DELETE
)

def live_DELETE(req, res) =
  main_source.skip()
  res.status_code(200)
end

Warning

request is a liquidsoap module

def queue_POST(request, response) =
  ...
  r = request.create(annotated) # !!!!
  ...

Use (req, res)

Dev/admin/user/listener impact

Push or pull

The scheduler can query the Liquidsoap process

The Liquidsoap process can query the scheduler

👌

A common YAML config

path_to_config = environment.get("RADIOZ_CONFIG")

let yaml.parse (conf:{
    scheduler:{
      bind:string,
    },
    liq_config:{
      api_port:float,
      token:string,
      start_sound:string,
    },
  }
}) = file.contents(path_to_config)

Contacting the scheduler process

def post_to_radioz(endpoint, data)
  res = http.post(
    "http://#{conf.scheduler.bind}#{endpoint}",
    headers=[
      ("Content-Type", "application/json; charset=UTF-8"),
      ("X-Auth-Token", conf.liq_config.token),
    ],
    data=json.stringify(data)
  )
  if res.status_code != 200
  then
    log(label="Warning", "Error while posting #{endpoint} to webapp: #{res} #{res.status_code} #{res.status_message}")
  end
  res
end

Authenticate incoming queries

harbor.http.middleware.register(check_token)

def check_token(req, res, next)
  if req.headers["x-auth-token"] != conf.liq_config.token then
    res.status_code(401) # Unauthorized
  else
    next(req, res)
  end
end

Common authentication

For end-users

def auth_function(login) =
  res = post_to_radioz("/can_live", login)
  if res.status_code == 200 then
    log("Access granted to #{login.user}")
    true
  else
    log("Access denied to #{login.user}")
    false
  end
end

live = input.harbor(auth=auth_function, port=9000, "live")

It's fast !

Pushing tracks & fading to live seamlessly.

It would be nice if...

We could know current file's path

So we could have an auto-reload !

file.watch(current_script_path(), restart)

Log like a web server

  • have the request's host/IP
  • access log in a dedicated file
  • def http_log(req, res, next)
      started_at = time()
      next(req, res)
      duration = time() - started_at
      log(label="http", "#{req.method} #{req.path} #{res.status_code.current()} #{duration}")
    end
    harbor.http.middleware.register(http_log)

    ⚠️ errors here will block the response silently ⚠️

Thanks for Liquidsoap

Follow progress @ sr.ht/~martink/radioz