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