This tutorial is out of date and no longer maintained.
Hello and welcome to this article! Today I would like to talk about creating an online streaming radio with the Ruby on Rails framework. This task is not that simple but it appears that by selecting the proper tools it can be solved without any big difficulties. By applying the concepts described in this tutorial you will be able to create your own radio station, share it with the world, and stream any music you like.
Our main technologies for today are going to be Rails (what a surprise!), Icecast streaming server, Sidekiq (to run background jobs), ruby-shout (to manage Icecast) and Shrine (to perform file uploading). In this article we will discuss the following topics:
Sounds promising, eh?
So, let’s get started, shall we?
We, as total dev geeks, are listening to a geek music, right (whatever it means)? So, the radio that we are going to create will, of course, be for the geeks only. Therefore create a new Rails application:
- rails new GeekRadio -T
I am going to use Rails 5.1 for this demo. The described concepts can be applied to earlier versions as well, but some commands may differ (for example, you should write rake generate
, not rails generate
in Rails 4).
First of all, we will require a model called Song
. The corresponding songs
table is going to store all the music tracks played on our radio. The table will have the following fields:
title
(string
)singer
(string
)track_data
(text
) — will be utilized by the Shrine gem which is going to take care of the file uploading functionality. Note that this field must have a _data
postfix as instructed by the docs.Run the following commands to create and apply a migration as well as the corresponding model:
- rails g model Song title:string singer:string track_data:text
- rails db:migrate
Now let’s create a controller to manage our songs:
class SongsController < ApplicationController
layout 'admin'
def index
@songs = Song.all
end
def new
@song = Song.new
end
def create
@song = Song.new song_params
if @song.save
redirect_to songs_path
else
render :new
end
end
private
def song_params
params.require(:song).permit(:title, :singer, :track)
end
end
This is a very trivial controller, but there are two things to note here:
admin
layout. This is because the main page of the website (with the actual radio player) will need to contain a different set of scripts and styles.track_data
, we are permitting the :track
attribute inside the song_params
private method. This is perfectly okay with Shrine — track_data
will only be used internally by the gem.Now let’s create an admin
layout that is going to include a separate admin.js
script and have no styling (though, of course, you are free to style it anything you like):
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>GeekRadio Admin</title>
<%= csrf_meta_tags %>
<%= javascript_include_tag 'admin', 'data-turbolinks-track': 'reload' %>
</head>
<body>
<%= yield %>
</body>
</html>
The app/assets/javascripts/admin.js
file is going to have the following contents:
//= require rails-ujs
//= require turbolinks
So, we are adding the built-in Unobtrusive JavaScript adapter for Rails and Turbolinks to make our pages load much faster.
Also, note that the admin.js
should be manually added to the precompile
array. Otherwise, when you deploy to production this file and all the required libraries won’t be properly prepared and you will end up with an error:
# ...
Rails.application.config.assets.precompile += %w( admin.js )
Now it is time to add some views and partials. Start with the index.html.erb
:
<h1>Songs</h1>
<p><%= link_to 'Add song', new_song_path %></p>
<%= render @songs %>
In order for the render @songs
construct to work properly we need to create a _song.html.erb
partial that will be used to display each song from the collection:
<div>
<p><strong><%= song.title %></strong> by <%= song.singer %></p>
</div>
<hr>
Now the new
view:
<h1>Add song</h1>
<%= render 'form', song: @song %>
And the _form
partial:
<%= form_with model: song do |f| %>
<div>
<%= f.label :title %>
<%= f.text_field :title %>
</div>
<div>
<%= f.label :singer %>
<%= f.text_field :singer %>
</div>
<div>
<%= f.label :track %>
<%= f.file_field :track %>
</div>
<%= f.submit %>
<% end %>
Note that here I am also using the :track
attribute, not :track_data
, just like in the SongsController
.
Lastly, add the necessary routes:
# ...
resources :songs, only: %i[new create index]
The next step involves integrating the Shrine gem that will allow to easily provide support for file uploads. After all, the site’s administrator should have a way to add some songs to be played on the radio. Surely, there are a bunch of other file uploading solutions, but I have chosen Shrine because of the following reasons:
But still, if for some reason you prefer another file uploading solution — no problem, the core functionality of the application will be nearly the same.
So, drop two new gems into the Gemfile
:
# ...
gem 'shrine'
gem "aws-sdk-s3", '~> 1.2'
Note that we are also adding the aws-sdk-s3 gem because I’d like to store our files on Amazon S3 right away. You may ask why haven’t I included aws-sdk gem instead? Well, recently this library has undergone some major changes and various modules are now split into different gems. This is actually a good thing because you can choose only the components that will be really used.
Install the gems:
- bundle install
Now create an initializer with the following contents:
require "shrine"
require "shrine/storage/file_system"
require "shrine/storage/s3"
s3_options = {
access_key_id: ENV['S3_KEY'],
secret_access_key: ENV['S3_SECRET'],
region: ENV['S3_REGION'],
bucket: ENV['S3_BUCKET']
}
Shrine.storages = {
cache: Shrine::Storage::FileSystem.new("tmp/uploads"),
store: Shrine::Storage::S3.new(upload_options: {acl: "public-read"}, prefix: "store",
**s3_options),
}
Shrine.plugin :activerecord
Here I am using ENV
to store my keys and other S3 settings because I don’t want them to be publically exposed. I will explain in a moment how to populate ENV
with all the necessary values.
Shrine has two storage locations: one for caching (file system, in our case) and one for permanent storage (S3). Apart from keys, region, and bucket, we are specifying two more settings for S3 storage:
upload_options
set ACL (access control list) settings for the uploaded files. In this case, we are providing the public-read
permission which means that all the files can be read by everyone. After all, that’s a public radio, isn’t it?prefix
means that all the uploaded files will end up in a store
folder. So, for example, if your bucket is named example
, the path to the file will be something like https://example.s3.amazonaws.com/store/your_file.mp3
Lastly, Shrine.plugin :activerecord
enables support for ActiveRecord. Shrine also has a :sequel
plugin by the way.
Now, what about the environment variables that will contain S3 settings? You may load them with the help of dotenv-rails gem really quickly. Add a new gem:
gem 'dotenv-rails'
Install it:
- bundle install
Then create an .env
file in the root of your project:
S3_KEY: YOUR_KEY
S3_SECRET: YOUR_SECRET
S3_BUCKET: YOUR_BUCKET
S3_REGION: YOUR_REGION
This file should be ignored by Git because you do not want to push it accidentally to GitHub or Bitbucket. Therefore let’s exclude it from version control by adding the following line to .gitignore
:
.env
Okay, we’ve provided some global configuration for Shrine and now it is time to create a special uploader class:
class SongUploader < Shrine
end
Inside the uploader, you may require additional plugins and customize everything as needed.
Lastly, include the uploader in the model and also add some basic validations:
class Song < ApplicationRecord
include SongUploader[:track]
validates :title, presence: true
validates :singer, presence: true
validates :track, presence: true
end
Note that Shrine has a bunch of special validation helpers that you may utilize as needed. I won’t do it here because, after all, this is not an article about Shrine.
Also, for our own convenience, let’s tweak the _song.html.erb
partial a bit to display a public link to the file:
<div>
<p><strong><%= song.title %></strong> by <%= song.singer %></p>
<%= link_to 'Listen', song.track.url(public: true) %> <!-- add this line -->
</div>
<hr>
Our job in this section is done. You may now boot the server by running:
- rails s
And try uploading some of your favorite tracks.
We are now ready to proceed to the next, a bit more complex part of this tutorial, where I’ll show you how to install and configure Icecast.
As mentioned above, Icecast is a streaming media server that can work with both audio and video, supporting Ogg, Opus, WebM, and MP3 streams. It works on all major operating systems and provides all the necessary tools for us to quite easily enable streaming radio functionality, so we are going to use it in this article.
First, navigate to the Downloads section and pick the version that works for you (at the time of writing this article the newest version was 2.4.3). Installation instructions for Linux users can be found on this page, whereas Windows users can simply use the installation wizard. Note, however, that Icecast has a bunch of prerequisites, so make sure you have all the necessary components on your PC.
Before booting the server, however, we need to tweak some configuration options. All Icecast global configuration is provided in the icecast.xml
file in the root of the installation directory. There are lots of settings that you can modify but I will list only the ones that we really require:
<hostname>localhost</hostname>
<authentication>
<!-- Sources log in with username 'source' -->
<source-password>PASSWORD</source-password>
<!-- Admin logs in with the username given below -->
<admin-user>admin</admin-user>
<admin-password>PASSWORD3</admin-password>
</authentication>
<shoutcast-mount>/stream</shoutcast-mount>
<listen-socket>
<port>35689</port>
</listen-socket>
<http-headers>
<header name="Access-Control-Allow-Origin" value="*" />
</http-headers>
So, we need to specify the following options:
/stream
.35689
). It means that in order to access our radio we will need to use the http://localhost:35689/stream
URL.Access-Control-Allow-Origin
header to easily embed the stream on other websites.After you are done with the settings, Icecast can be started by running the icecast
file from the command line interface (for Windows there is an icecast.bat
file).
You may also visit the http://localhost:35689
to see a pretty minimalistic web interface.
Note that in order to visit the Administration section, you will need to provide an admin’s password.
Great, now our Icecast server is up and running but we need to manipulate it somehow and perform the actual streaming. Let’s proceed to the next section and take care of that!
Icecast provides bindings for a handful of popular languages, including Python, Java, and Ruby. The gem for Ruby is called ruby-shout and we are going to utilize it in this article. Ruby-shout allows us to easily connect to Icecast, change information about the server (like its name or genre of the music), and, of course, perform the actual streaming.
Ruby-shout relies on the libshout base library that can also be downloaded from the official website. The problem is that this library is not really designed to work with Windows (there might be a way to compile it but I have not found it). So, if you are on Windows, you’ll need to stick with Cygwin. Install libshout from there and perform all the commands listed below from the Cygwin CLI.
Drop ruby-shout
into the Gemfile
:
# ...
gem 'ruby-shout'
We also need decide what tool are we going to use to perform streaming in the background. There are multiple possible ways to solve this task but I propose to stick with a popular gem called Sidekiq that makes working with background jobs a breeze:
# ...
gem 'sidekiq'
Now install everything:
- bundle install
One thing to note is that Sidekiq relies on Redis, so don’t forget to install and run it as well.
So, the idea is quite simple:
current
attribute set to true
.Before creating our worker, let’s generate a new migration to add a current
field to the songs
table:
- rails g migration add_current_to_songs current:boolean:index
Tweak the migration a bit to make the current
attribute default to false
:
# ...
add_column :songs, :current, :boolean, default: false
Apply the migration:
- rails db:migrate
Now create a new Sidekiq worker:
require 'shout'
require 'open-uri'
class RadioWorker
include Sidekiq::Worker
def perform(*_args)
end
end
shout
is our ruby-shout gem, whereas open-uri
will be used to open the tracks uploaded to Amazon.
Inside the perform
action we firstly need to connect to Icecast and provide some settings:
# ...
def perform(*_args)
s = Shout.new
s.mount = "/stream"
s.charset = "UTF-8"
s.port = 35689
s.host = 'localhost'
s.user = ENV['ICECAST_USER']
s.pass = ENV['ICECAST_PASSWORD']
s.format = Shout::MP3
s.description = 'Geek Radio'
s.connect
end
The username and the password are taken from the ENV
so add two more line to the .env
file:
# ...
ICECAST_USER: source
ICECAST_PASSWORD: PASSWORD_FOR_SOURCE
The username is always source
whereas the password should be the same as the one you specified in the icecast.xml
inside the source-password
tag.
Now I’d like to keep track of the previously played song and iterate over all the songs in an endless loop:
# ...
def perform(*_args)
# ...
s.connect
prev_song = nil
loop do
Song.where(current: true).each do |song|
song.toggle! :current
end
Song.order('created_at DESC').each do |song|
prev_song.toggle!(:current) if prev_song
song.toggle! :current
end
end
s.disconnect
end
Before iterating over the songs, we are making sure the current
attribute is set to false
for all the songs (just to be on the safe side, because the worker might crash). Then we take one song after another and mark it as current
.
Now let’s open the track file, add information about the currently played song and perform the actual streaming:
# ...
def perform(*_args)
# ...
loop do
# ...
Song.order('created_at DESC').each do |song|
prev_song.toggle!(:current) if prev_song
song.toggle! :current
open(song.track.url(public: true)) do |file|
m = ShoutMetadata.new
m.add 'filename', song.track.original_filename
m.add 'title', song.title
m.add 'artist', song.singer
s.metadata = m
while data = file.read(16384)
s.send data
s.sync
end
end
prev_song = song
end
end
end
Here we are opening the track file using the open-uri library and set some metadata about the currently played song. original_filename
is the method provided by Shrine (it stored the original filename internally), whereas title
and singer
are just the model’s attributes. Then we are reading the file (16384
is the block size) and send portions of it to Icecast.
Here is the final version of the worker:
require 'shout'
require 'open-uri'
class RadioWorker
include Sidekiq::Worker
def perform(*_args)
prev_song = nil
s = Shout.new # ruby-shout instance
s.mount = "/stream" # our mountpoint
s.charset = "UTF-8"
s.port = 35689 # the port we've specified earlier
s.host = 'localhost' # hostname
s.user = ENV['ICECAST_USER'] # credentials
s.pass = ENV['ICECAST_PASSWORD']
s.format = Shout::MP3 # format is MP3
s.description = 'Geek Radio' # an arbitrary name
s.connect
loop do # endless loop to perform streaming
Song.where(current: true).each do |song| # make sure all songs are not `current`
song.toggle! :current
end
Song.order('created_at DESC').each do |song|
prev_song.toggle!(:current) if prev_song # if there was a previously played song, set `current` to `false`
song.toggle! :current # a new song is playing so it is `current` now
open(song.track.url(public: true)) do |file| # open the public URL
m = ShoutMetadata.new # add metadata
m.add 'filename', song.track.original_filename
m.add 'title', song.title
m.add 'artist', song.singer
s.metadata = m
while data = file.read(16384) # read the portions of the file
s.send data # send portion of the file to Icecast
s.sync
end
end
prev_song = song # the song has finished playing
end
end # end of the endless loop
s.disconnect # disconnect from the server
end
end
In order to run this worker upon server boot, create a new initializer:
RadioWorker.perform_async
To see everything it in action, make sure Icecast is started, then boot Sidekiq:
- bundle exec sidekiq
and start the server:
- rails s
In the Sidekiq’s console you should see a similar output:
Examine:
2017-11-01T17:30:03.727Z 7752 TID-5xikpsg RadioWorker JID-57462d72129bbd0655d2e853 INFO: start
This line means that our background job is running which means that the radio is now live! Try visiting http://localhost:35689/stream
— you should hear your music playing.
Nice, the streaming functionality is done! Our next task is to display the actual player and connect to the stream, so proceed to the next part.
I would like to display the radio player on the root page of our website. Let’s create a new controller to manage semi-static website pages:
# app/controllers/pages_controller.rb
class PagesController < ApplicationController
end
Add a new root route:
# ...
root 'pages#index'
Now create a new view with an audio
element:
<h1>Geek Radio</h1>
<div id="js-player-wrapper">
<audio id="js-player" controls>
Your browser does not support the <code>audio</code> element. Seems like you are not a geek :(
</audio>
</div>
Of course, audio
will render a very minimalistic player with a very limited set of customization options. There are a handful of third-party libraries allowing you to replace the built-in player with something more customizable, but I think for the purposes of this article the generic solution will work just fine.
Now it is time to write some JavaScript code. I would actually like to stick with jQuery to simplify manipulating the elements and performing AJAX calls later, but the same task can be solved with vanilla JS. Add a new gem to the Gemfile
(newer versions of Rails do no have jquery-rails anymore):
gem 'jquery-rails'
Install it:
- bundle install
Tweak the app/assets/javascripts/application.js
file to include jQuery and our custom player.coffee
which will be created in a moment:
//= require jquery3
//= require player
Note that we do not need rails-ujs or Turbolinks here because our main section of the site is very simple and consists of only one page.
Now create the app/assets/javascripts/player.coffee
file (be very careful about indents as CoffeeScript relies heavily on them):
$ ->
wrapper = $('#js-player-wrapper')
player = wrapper.find('#js-player')
player.append '<source src="http://localhost:35689/stream">'
player.get(0).play()
I’ve decided to be more dynamic here but you may add the source
tag right inside your view. Note that in order to manipulate the player, you need to turn jQuery wrapped set to a JavaScript node by saying player.get(0)
.
Visit the http://localhost:3000
and make sure the player is there and Geek radio is actually live!
The radio is now working, but the problem is the users do not see what track is currently playing. Some people may not even care about this, but when I hear some good composition, I really want to know who sings it. So, let’s introduce this functionality now.
What’s interesting, there is no obvious way to see the name of the currently played song even though this meta information is added by us inside the RadioWorker
. It appears that the easiest solution would be to stick with good old XSL templates (well, maybe they are not that good actually). Go to the directory where Icecast is installed, open the web
folder, and create a new .xsl
file there, for example info.xsl
. Now it’s time for some ugly-looking code:
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="2.0">
<xsl:output omit-xml-declaration="yes" method="text" indent="no" media-type="text/javascript" encoding="UTF-8"/>
<xsl:strip-space elements="*"/>
<xsl:template match="/icestats">
parseMusic({
<xsl:for-each select="source">
"<xsl:value-of select="@mount"/>":
{
"server_name":"<xsl:value-of select="server_name"/>",
"listeners":"<xsl:value-of select="listeners"/>",
"description":"<xsl:value-of select="server_description" />",
"title":"<xsl:if test="artist"><xsl:value-of select="artist" /> - </xsl:if><xsl:value-of select="title" />",
"genre":"<xsl:value-of select="genre" />"
}
<xsl:if test="position() != last()"><xsl:text>,</xsl:text></xsl:if>
</xsl:for-each>
});
</xsl:template>
</xsl:stylesheet>
You might ask: “What’s going on here?”. Well, this file generates a JSONP callback parseMusic
for us that displays information about all the streams on the Icecast server. Each stream has the following data:
server_name
listeners
— Icecast updates listeners count internallydescription
title
— displays both title and the singer’s name (if it is available)genre
Now reboot your Icecast and navigate to http://localhost:35689/info.xsl
. You should see an output similar to this:
parseMusic({
"/stream":
{
"server_name":"no name",
"listeners":"0",
"description":"Geek Radio",
"title":"some title - some singer",
"genre":"various"
}
});
As you see, the mount point’s URL (/stream
in this case) is used as the key. The value is an object with all the necessary info. It means that the JSONP callback is available for us!
Tweak the index
view by adding a new section inside the #js-player-wrapper
block:
<div id="js-player-wrapper">
<p>
Now playing: <strong class="now-playing"></strong>
Listeners: <span class="listeners"></span>
</p>
<audio id="js-player" controls>
Your browser does not support the <code>audio</code> element. Seems like you are not a geek :(
</audio>
</div>
Next, define two new variables to easily change the contents of the corresponding elements later:
$ ->
wrapper = $('#js-player-wrapper')
player = wrapper.find('#js-player')
now_playing = wrapper.find('.now-playing')
listeners = wrapper.find('.listeners')
# ...
Create a function to send an AJAX request to our newly added URL:
$ ->
# ... your variables defined here
updateMetaData = ->
url = 'http://localhost:35689/info.xsl'
mount = '/stream'
$.ajax
type: 'GET'
url: url
async: true
jsonpCallback: 'parseMusic'
contentType: "application/json"
dataType: 'jsonp'
success: (data) ->
mount_stat = data[mount]
now_playing.text mount_stat.title
listeners.text mount_stat.listeners
error: (e) -> console.error(e)
We are setting jsonpCallback
to parseMusic
— this function should be generated for us by the XSL template. Inside the success
callback we are then updating the text for the two blocks as needed.
Of course, this data should be updated often, so let’s set an interval:
$ ->
# ... variables and function
player.get(0).play()
setInterval updateMetaData, 5000
Now every 5 seconds the script will send an asynchronous GET request in order to update the information. Test it out by reloading the root page of the website.
The browser’s console should have an output similar to this one:
Here we can see that the GET requests with the proper callback are regularly sent to Icecast server and that the response contains all the data. Great!
“That’s nice but I want even more info about the played song!”, you might say. Well, let me show you a way to extract additional meta-information about the track and display it later to the listener. For example, let’s fetch the track’s bitrate.
In order to do this we’ll need two things:
Firstly, add the gem:
gem 'streamio-ffmpeg'
Note that this gem requires ffmpeg tool to be present on your PC, so firstly download and install it. Next, install the gem itself:
- bundle install
Now enable a new Shrine plugin:
# ...
Shrine.plugin :add_metadata
Tweak the uploader to fetch the song’s bitrate:
class SongUploader < Shrine
add_metadata do |io, context|
song = FFMPEG::Movie.new(io.path)
{
bitrate: song.bitrate ? (song.bitrate / 1000) : 0
}
end
end
The hash returned by add_metadata
will be properly added to the track_data
column. This operation will be performed before the song is actually saved, so you do not need to do anything else.
Next, tweak the worker a bit to add provide bitrate information:
# ...
def perform(*_args)
# ...
open(song.track.url(public: true)) do |file|
# ...
m.add 'bitrate', song.track.metadata['bitrate'].to_s
end
end
Don’t forget to modify the XSL template:
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="2.0">
<xsl:output omit-xml-declaration="yes" method="text" indent="no" media-type="text/javascript" encoding="UTF-8"/>
<xsl:strip-space elements="*"/>
<xsl:template match="/icestats">
parseMusic({
<xsl:for-each select="source">
"<xsl:value-of select="@mount"/>":
{
"server_name":"<xsl:value-of select="server_name"/>",
"listeners":"<xsl:value-of select="listeners"/>",
"description":"<xsl:value-of select="server_description" />",
"title":"<xsl:if test="artist"><xsl:value-of select="artist" /> - </xsl:if><xsl:value-of select="title" />",
"genre":"<xsl:value-of select="genre" />",
"bitrate":"<xsl:value-of select="bitrate" />"
}
<xsl:if test="position() != last()"><xsl:text>,</xsl:text></xsl:if>
</xsl:for-each>
});
</xsl:template></xsl:stylesheet>
Add a new span
tag to the view:
<div id="js-player-wrapper">
<p>
Now playing: <strong class="now-playing"></strong>
Listeners: <span class="listeners"></span>
Bitrate: <span class="bitrate"></span>
</p>
<audio id="js-player" controls>
Your browser does not support the <code>audio</code> element. Seems like you are not a geek :(
</audio>
</div>
Lastly, define a new variable and update bitrate information inside the success
callback:
$ ->
bitrate = wrapper.find('.bitrate')
.ajax
success: (data) ->
mount_stat = data[mount]
now_playing.text mount_stat.title
listeners.text mount_stat.listeners
bitrate.text mount_stat.bitrate
Upload a new track and its bitrate should now be displayed for you. This is it! You may use the described approach to provide any other information you like, for example, the track’s duration or the album’s name. Don’t be afraid to experiment!
In this article, we have covered lots of different topics and created our own online streaming radio powered by Rails and Icecast. You have seen how to:
Some of these concepts may seem complex for beginners, so don’t be surprised if something does not work for you right away (usually, all the installations cause the biggest pain). Don’t hesitate to post your questions if you are stuck — together we will surely find a workaround.
I really hope this article was entertaining and useful for you. If you manage to create a public radio station following this guide, do share it with me. I thank you for reading this tutorial and happy coding!
Thanks for learning with the DigitalOcean Community. Check out our offerings for compute, storage, networking, and managed databases.
This textbox defaults to using Markdown to format your answer.
You can type !ref in this text area to quickly search our full set of tutorials, documentation & marketplace offerings and insert the link!
Sign up for Infrastructure as a Newsletter.
Working on improving health and education, reducing inequality, and spurring economic growth? We'd like to help.
Get paid to write technical tutorials and select a tech-focused charity to receive a matching donation.