radio

a command line radio player, using mpd/mpv as a backend
git clone git://git.alex.balgavy.eu/radio.git
Log | Files | Refs | README | LICENSE

radio (7144B)


      1 #!/usr/bin/env ruby
      2 # frozen_string_literal: true
      3 
      4 DEFAULT_CONFIG_FILE = '/usr/local/etc/radio/urls'
      5 CONFIG_FILE = "#{ENV['HOME']}/.config/radio/urls"
      6 
      7 def clear_screen
      8   puts "\e[H\e[2J"
      9 end
     10 
     11 def get_user_choice(list)
     12   list.each_with_index do |elem, i|
     13     puts "#{i + 1}: #{elem}"
     14   end
     15   print 'Enter number or press ^C to go back> '
     16   begin
     17     (Integer(gets) - 1)
     18   rescue StandardError
     19     false
     20   end
     21 end
     22 
     23 def choose_from_list(list, names)
     24   clear_screen
     25   puts '== Internet Radio Player =='
     26   until (user_selection = get_user_choice(names))
     27     puts 'Invalid selection, please try again.' if user_selection && !list[user_selection]
     28   end
     29 
     30   list[user_selection]
     31 end
     32 
     33 # Radio: the base radio class
     34 class Radio
     35   attr_reader :channel
     36 
     37   def initialize
     38     if system('command -v mpc 1>/dev/null 2>&1')
     39       @player = 'mpc'
     40     elsif system('command -v mpv 1>/dev/null 2>&1')
     41       @player = 'mpv'
     42     else
     43       warn 'neither mpc nor mpv installed.'
     44       exit 1
     45     end
     46   end
     47 
     48   # Adding stream to mpc is handled by subclass, because add/load might vary depending on stream
     49   def play(stream)
     50     if @player == 'mpc'
     51       system 'mpc', 'play'
     52     else
     53       clear_screen
     54       puts "Loading #{stream}..."
     55       system 'mpv', stream, '--vid=no', '--volume=50'
     56     end
     57   end
     58 end
     59 
     60 class WFMU < Radio
     61   HOSTNAME = 'wfmu.org'
     62   def initialize(selected_channel)
     63     super()
     64     @channel = selected_channel[:link]
     65   end
     66 
     67   def play
     68     if @player == 'mpc'
     69       system 'mpc', 'clear', 1 => '/dev/null'
     70       system 'mpc', 'load', @channel
     71     end
     72     super @channel
     73   end
     74 end
     75 
     76 # SomaFM radio subclass
     77 class SomaFM < Radio
     78   HOSTNAME = 'somafm.com'
     79 
     80   def initialize(selected_channel)
     81     super()
     82     @channel = selected_channel[:link]
     83   end
     84 
     85   def play
     86     if @player == 'mpc'
     87       system 'mpc', 'clear', 1 => '/dev/null'
     88       system('mpc', 'load', @channel)
     89     end
     90     super @channel
     91   end
     92 end
     93 
     94 # Radios with a direct stream link
     95 class OtherRadio < Radio
     96   def initialize(selected_channel)
     97     super()
     98     @channel = selected_channel[:link]
     99   end
    100 
    101   def play
    102     if @player == 'mpc'
    103       system 'mpc', 'clear', 1 => '/dev/null'
    104       if @channel =~ /\.m3u$/
    105         system 'mpc', 'load', @channel
    106       else
    107         system 'mpc', 'add', @channel
    108       end
    109     end
    110     super @channel
    111   end
    112 end
    113 
    114 # Sounds of Earth
    115 class SoundsOfEarth < Radio
    116   require 'json'
    117   require 'open-uri'
    118 
    119   def initialize(_)
    120     channels = retrieve_channels
    121     @channel = choose_from_list(channels.map { |c| c[:link] }, channels.map { |c| c[:name] }) while @channel.nil?
    122     super()
    123   rescue Interrupt
    124     @channel = nil
    125   end
    126 
    127   def play
    128     if @player == 'mpc'
    129       system 'mpc', 'clear', 1 => '/dev/null'
    130       system 'mpc', 'add', @channel
    131     end
    132     super @channel
    133   end
    134 
    135   private
    136 
    137   def retrieve_channels
    138     URI('https://soundsofearth.eco/regions.json').open do |response|
    139       streams = JSON.parse(response.read)['results']
    140       streams.map { |stream| { name: "#{stream['name']} (#{stream['description']})", link: stream['sound'] } }
    141     end
    142   rescue OpenURI::HTTPError
    143     []
    144   end
    145 end
    146 
    147 # RadioGarden global radios
    148 class RadioGarden < Radio
    149   require 'json'
    150   require 'open-uri'
    151   require 'cgi'
    152 
    153   def initialize(_)
    154     @base_url = 'https://radio.garden/api'
    155     puts 'No radio found, please try again.' while (retrieved_channels = search_channels).empty?
    156     channels = parse_channels(retrieved_channels)
    157     selected_channel = choose_from_list(channels, channels.map { |c| c[:name] }) while selected_channel.nil?
    158     @channel = get_channel_link(selected_channel)
    159     super()
    160   rescue Interrupt
    161     @channel = nil
    162   end
    163 
    164   def play
    165     if @player == 'mpc'
    166       system 'mpc', 'clear', 1 => '/dev/null'
    167       system 'mpc', 'add', @channel
    168     end
    169     super @channel
    170   end
    171 
    172   private
    173 
    174   def search_channels
    175     print 'Enter radio search: '
    176     query = gets.chomp
    177     URI.parse("#{@base_url}/search?q=#{CGI.escape query}").open do |response|
    178       JSON.parse(response.read)['hits']['hits']
    179     end
    180   rescue OpenURI::HTTPError
    181     []
    182   end
    183 
    184   def get_channel_link(selected_channel)
    185     # Will redirect
    186     @channel = URI.parse("#{@base_url}/ara/content/listen/#{selected_channel[:id]}/channel.mp3").open(redirect: false)
    187   rescue OpenURI::HTTPRedirect => e
    188     @channel = e.uri.to_s.gsub(/\?listening-from.*/, '')
    189   rescue OpenURI::HTTPError
    190     @channel = None
    191   end
    192 
    193   def parse_channels(retrieved_channels)
    194     retrieved_channels.inject([]) do |channels, c|
    195       channels << { name: "#{c['_source']['title']} (#{c['_source']['subtitle']})",
    196                     id: c['_source']['channelId'] }
    197     end
    198   end
    199 end
    200 
    201 # Play music from a subreddit
    202 class Subreddit < Radio
    203   require 'json'
    204   require 'open-uri'
    205   require 'shellwords'
    206 
    207   def initialize(_)
    208     puts 'Subreddit has no music posts or does not exist.' while (posts = retrieve_subreddit_posts).empty?
    209     @links = extract_post_links(posts)
    210     super()
    211   rescue Interrupt
    212     @channel = nil
    213   end
    214 
    215   def play
    216     puts "Number of tracks: #{@links.length}"
    217     # TODO: support mpd
    218     system("mpv --vid=no --volume=50 -- #{@links.map { |l| l[:url] }.shelljoin}")
    219   end
    220 
    221   private
    222 
    223   def retrieve_subreddit_posts
    224     print 'Enter subreddit name: '
    225     sub = gets.chomp
    226     url = "https://www.reddit.com/r/#{sub}/top.json?t=month&limit=100&show=all"
    227     URI.parse(url).open('User-Agent' => 'ruby/2.7', 'Accept' => 'application/json') do |response|
    228       @channel = sub
    229       JSON.parse(response.read)['data']['children']
    230     end
    231   rescue OpenURI::HTTPError
    232     []
    233   end
    234 
    235   def extract_post_links(posts)
    236     posts.each_with_object([]) do |post, links|
    237       p = post['data']
    238       if !p['is_self'] && p['post_hint'] != 'image'
    239         links.append(title: p['title'], url: p['url'], reddit: "https://reddit.com#{p['permalink']}")
    240       end
    241     end
    242   end
    243 end
    244 
    245 def read_config_file(cfg)
    246   channels = []
    247   ignore = /^\s*(\#|\s*$)/
    248   File.open(cfg, 'r').each do |line|
    249     next if line.match?(ignore)
    250 
    251     parts = line.chomp.split(/(?<=")\s+(?=http)/)
    252     channel = { name: parts.first.gsub('"', ''), link: parts.last }
    253     case URI.parse(channel[:link]).host
    254     when SomaFM::HOSTNAME
    255       channel[:radio] = SomaFM
    256     when WFMU::HOSTNAME
    257       channel[:radio] = WFMU
    258     else
    259       channel[:radio] = OtherRadio
    260     end
    261     channels << channel
    262   end
    263 
    264   channels
    265 end
    266 
    267 def load_channels_from_config
    268   if File.exist? CONFIG_FILE
    269     cfg = CONFIG_FILE
    270   elsif File.exist? DEFAULT_CONFIG_FILE
    271     cfg = DEFAULT_CONFIG_FILE
    272   else
    273     warn "Please set URLs in #{ENV['HOME']}/.config/radio/urls."
    274     exit 1
    275   end
    276   read_config_file(cfg)
    277 end
    278 
    279 channels = load_channels_from_config + [{ name: 'RadioGarden', radio: RadioGarden },
    280                                         { name: 'Sounds of Earth', radio: SoundsOfEarth },
    281                                         { name: 'Subreddit', radio: Subreddit }]
    282 
    283 begin
    284   radio = nil
    285   loop do
    286     selected_channel = choose_from_list(channels, channels.map { |c| c[:name] }) while selected_channel.nil?
    287     radio = selected_channel[:radio].new(selected_channel)
    288     break unless selected_channel.nil? || radio.channel.nil?
    289   end
    290   radio.play
    291 rescue Interrupt
    292   exit 0
    293 end