├── .gitmodules ├── README.md ├── conf ├── listen.conf ├── modules.conf └── name.conf ├── data └── Welcome │ └── config.json ├── main.php └── modules └── SHOUTcast ├── Buffer.php ├── Metadata.php ├── Stream.php └── Welcome.php /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule ".modfwango"] 2 | path = .modfwango 3 | url = https://github.com/Modfwango/Modfwango.git 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Radio-PHP 2 | ========= 3 | 4 | Radio-PHP is a streaming server that uses the SHOUTcast client protocol in order 5 | to stream a directory of music files without the need for an external streaming 6 | source (such as Traktor or SAM Broadcoaster). Radio-PHP makes this possible by 7 | using a few utilities available in many Linux package repositories to convert 8 | your input files to a homogeneous MP3 format. 9 | 10 | Install 11 | ======= 12 | 13 | First, make sure that you have the necessary packages installed: 14 | ```sh 15 | sudo apt-get install -y libav-tools libmp3lame-dev php5-cli 16 | ``` 17 | 18 | After installing the necessary packages, clone this repository and its 19 | submodules: 20 | ```sh 21 | git clone https://github.com/Modfwango/Radio-PHP.git 22 | cd Radio-PHP && git submodule update --init 23 | ``` 24 | 25 | Next, configure the options in the `data/Welcome/config.json` file to your 26 | liking. After you have configured Radio-PHP, place some media in the folder 27 | specified in the config and start the daemon with `php main.php`. By default 28 | Radio-PHP listens on all interfaces on port `8000`. This can be changed in 29 | `conf/listen.conf` 30 | 31 | Development 32 | =========== 33 | 34 | In order to develop your own features and such, take a look at 35 | [this link](http://modfwango.com/Modfwango/blob/master/README.md) for more 36 | information. 37 | 38 | Licensing 39 | ========= 40 | 41 | This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 42 | International License. To view a copy of this license, visit 43 | http://creativecommons.org/licenses/by-sa/4.0/ or send a letter to Creative 44 | Commons, PO Box 1866, Mountain View, CA 94042, USA. 45 | -------------------------------------------------------------------------------- /conf/listen.conf: -------------------------------------------------------------------------------- 1 | 0.0.0.0,8000 2 | -------------------------------------------------------------------------------- /conf/modules.conf: -------------------------------------------------------------------------------- 1 | SHOUTcast/Buffer 2 | SHOUTcast/Metadata 3 | SHOUTcast/Stream 4 | SHOUTcast/Welcome 5 | -------------------------------------------------------------------------------- /conf/name.conf: -------------------------------------------------------------------------------- 1 | radio-php 2 | -------------------------------------------------------------------------------- /data/Welcome/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "bitrate": 192, 3 | "description": "Example station description.", 4 | "genre": "Various Genres", 5 | "name": "Untitled Station", 6 | "music": "\/var\/private\/music", 7 | "preload": 3, 8 | "repeatfreq": 30, 9 | "samplerate": 44100, 10 | "url": "http:\/\/example.org\/" 11 | } -------------------------------------------------------------------------------- /main.php: -------------------------------------------------------------------------------- 1 | 0) { 48 | echo "Some mandatory configuration files were missing, and thus replaced. ". 49 | "They are listed below:".$ending.implode($ending, $missing)."\n"; 50 | exit(0); 51 | } 52 | 53 | // End if prelaunch is requested 54 | if (isset($argv[1]) && strtolower($argv[1]) == "prelaunch") { 55 | exit(0); 56 | } 57 | 58 | // Require Modfwango core to ignite the project 59 | require_once(__PROJECTROOT__."/.modfwango/main.php"); 60 | ?> 61 | -------------------------------------------------------------------------------- /modules/SHOUTcast/Buffer.php: -------------------------------------------------------------------------------- 1 | pipes[1])) { 14 | // Read "burstint" bytes from the encoder 15 | $length = $this->welcome->getOption("burstint"); 16 | $data = @fread($this->pipes[1], $length); 17 | // Pad the data read from the encoder if necessary 18 | if (strlen($data) < $length) 19 | $data .= str_repeat(chr(0), $length - strlen($data)); 20 | // Add the data to the stream pool 21 | $this->stream->putPool($data); 22 | } 23 | 24 | // If the encoder is finished ... 25 | if (!is_resource($this->process) || feof($this->pipes[1])) { 26 | // Close the process handle and reset the pipes 27 | @proc_close($this->process); 28 | $this->process = null; 29 | $this->pipes = null; 30 | 31 | // Switch to the next song 32 | $this->stream->nextSong(); 33 | $this->song = $this->stream->getSong(); 34 | 35 | // Prepare some clean pipes 36 | $pipes = array( 37 | 0 => array("pipe", "r"), 38 | 1 => array("pipe", "w"), 39 | 2 => array("pipe", "a") 40 | ); 41 | // Build the command to run the encoder 42 | $cmd = "avconv -v quiet -i ".escapeshellarg($this->song)." -c ". 43 | "libmp3lame -ar ".$this->welcome->getOption("samplerate")." -ab ". 44 | $this->welcome->getOption("bitrate")."k -minrate ". 45 | $this->welcome->getOption("bitrate")."k -maxrate ". 46 | $this->welcome->getOption("bitrate")."k -f mp3 -"; 47 | Logger::debug($cmd); 48 | // Open the encoder process and set the pipes to non-blocking mode 49 | $this->process = proc_open($cmd, $pipes, $this->pipes); 50 | stream_set_blocking($this->pipes[0], 0); 51 | stream_set_blocking($this->pipes[1], 0); 52 | stream_set_blocking($this->pipes[2], 0); 53 | } 54 | } 55 | 56 | public function isInstantiated() { 57 | // Fetch references to required modules 58 | $this->stream = ModuleManagement::getModuleByName("Stream"); 59 | $this->welcome = ModuleManagement::getModuleByName("Welcome"); 60 | 61 | // Register an event to periodically check the encoder state 62 | EventHandling::registerForEvent("connectionLoopEndEvent", $this, 63 | "receiveConnectionLoopEnd"); 64 | return true; 65 | } 66 | } 67 | ?> 68 | -------------------------------------------------------------------------------- /modules/SHOUTcast/Metadata.php: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /modules/SHOUTcast/Stream.php: -------------------------------------------------------------------------------- 1 | 0) { 17 | // Build the buffer with the requested number of bytes 18 | $buf = substr($this->pool, 0, $bytes); 19 | // If a flush is desired, remove the requested buffer from the pool 20 | if ($flush == true) 21 | $this->pool = substr($this->pool, $bytes); 22 | } 23 | else { 24 | // Grab the entire pool 25 | $buf = $this->pool; 26 | // Empty the pool 27 | if ($flush == true) 28 | $this->pool = null; 29 | } 30 | return $buf; 31 | } 32 | 33 | public function getClients() { 34 | $clients = array(); 35 | // Build a list of all clients ready to receive broadcast data 36 | foreach (ConnectionManagement::getConnections() as $client) 37 | if (is_object($client) && $client->isAlive() && 38 | $client->getOption("ready") == true) 39 | $clients[] = $client; 40 | return $clients; 41 | } 42 | 43 | public function getSong() { 44 | // Get the currently playing song 45 | return (isset($this->history[0]) ? $this->history[0] : false); 46 | } 47 | 48 | public function getSongs() { 49 | $songs = array(); 50 | // Build a list of all songs available in the configured music directory 51 | foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator( 52 | $this->welcome->getOption("music"))) as $file => $obj) 53 | if (is_file($file) && is_readable($file)) 54 | $songs[] = $file; 55 | return $songs; 56 | } 57 | 58 | public function nextSong() { 59 | // Calculate the repeat frequency based on the current count of songs and 60 | // the configured value 61 | $repeatfreq = $this->welcome->getOption("repeatfreq"); 62 | $repeatfreq = ($repeatfreq > count($this->getSongs()) ? 63 | count($this->getSongs()) : $repeatfreq); 64 | // Log the max history length calculated above and the play history before 65 | // pruning 66 | Logger::debug("Max history length: ".$repeatfreq); 67 | Logger::debug("History before prune:"); 68 | Logger::debug(var_export($this->history, true)); 69 | // Prune the play history to the size allowed by the repeat frequency 70 | while (count($this->history) >= $repeatfreq) 71 | array_pop($this->history); 72 | // Log the play history after pruning 73 | Logger::debug("History after prune:"); 74 | Logger::debug(var_export($this->history, true)); 75 | // Get an array of possible songs by excluding tracks in the updated play 76 | // history and randomize it 77 | $selections = array_diff($this->getSongs(), $this->history); 78 | shuffle($selections); 79 | // Log the current playlist 80 | Logger::debug("Possible songs:"); 81 | Logger::debug(var_export($selections, true)); 82 | // Play the first item on the list 83 | array_unshift($this->history, $selections[0]); 84 | $this->meta = $this->metadata->getMetadata($this->getSong()); 85 | Logger::debug("Switching to song \"".$this->getSong()."\"..."); 86 | Logger::debug($this->meta); 87 | } 88 | 89 | public function putPool($buf) { 90 | // Append the provided buffer to the pool of data waiting to be sent 91 | $this->pool .= $buf; 92 | } 93 | 94 | public function broadcast() { 95 | // // Schedule another broadcast period 96 | // $this->scheduleBroadcast(); 97 | 98 | // If there are clients connected ... 99 | if (count($this->getClients()) > 0) { 100 | $burstint = $this->welcome->getOption("burstint"); 101 | // and there is ample data to broadcast ... 102 | if (strlen($this->pool) >= $burstint) { 103 | // fetch the data associated with this broadcast ... 104 | $buf = $this->getPool($burstint); 105 | // and process the data for each client 106 | foreach ($this->getClients() as $client) { 107 | // If the client specified that it wants metadata, append the 108 | // associated metadata to the data 109 | $data = $buf.($client->getOption("metadata") ? $this->meta : null); 110 | // If the client is still waiting for preload data ... 111 | if ($client->getOption("preload") >= 0) { 112 | // and the client still has insufficient data for the configured 113 | // preload amount ... 114 | if ($client->getOption("preload") > 0) 115 | // append this broadcast to the preload buffer for this client 116 | $client->setOption("preloadbuf", 117 | $client->getOption("preloadbuf").$data); 118 | else { 119 | // If the client's preload is fully prepared, send it and clear 120 | // the preload buffer 121 | $client->send($client->getOption("preloadbuf"), false); 122 | $client->setOption("preloadbuf", false); 123 | } 124 | // Decrement the preload quantity 125 | $client->setOption("preload", $client->getOption("preload") - 1); 126 | } 127 | // If the client is not waiting for preload data, send the data in a 128 | // regular fashion 129 | if ($client->getOption("preload") < 0) $client->send($data, false); 130 | } 131 | } 132 | } 133 | else if (count(ConnectionManagement::getConnections()) < 1) 134 | // Clear the pool if no clients are connected 135 | $this->getPool(); 136 | } 137 | 138 | private function scheduleBroadcast() { 139 | // Create a timer to call $this->broadcast() 140 | $this->timer->newTimer($this->welcome->getOption("rate"), $this, 141 | "broadcast", null); 142 | } 143 | 144 | public function isInstantiated() { 145 | // Fetch references to required modules 146 | $this->metadata = ModuleManagement::getModuleByName("Metadata"); 147 | $this->timer = ModuleManagement::getModuleByName("Timer"); 148 | $this->welcome = ModuleManagement::getModuleByName("Welcome"); 149 | 150 | // Fetch a null metadata payload 151 | $this->meta = $this->metadata->getMetadata(null); 152 | 153 | // // Schedule a broadcast to all clients 154 | // $this->scheduleBroadcast(); 155 | 156 | EventHandling::registerForEvent("connectionLoopEndEvent", $this, 157 | "broadcast"); 158 | return true; 159 | } 160 | } 161 | ?> 162 | -------------------------------------------------------------------------------- /modules/SHOUTcast/Welcome.php: -------------------------------------------------------------------------------- 1 | config[$name]) ? $this->config[$name] : false); 10 | } 11 | 12 | public function loadConfig() { 13 | $config = @json_decode(StorageHandling::loadFile($this, "config.json"), 14 | true); 15 | if (is_array($config)) { 16 | // Array of required fields in $config 17 | $required = array("bitrate", "description", "genre", "name", "music", 18 | "samplerate", "url"); 19 | 20 | // Pre-formatted strings for use in multiple error messages 21 | $not_defined = "The configuration option '%s' was not defined in the ". 22 | "configuration file at %s."; 23 | $invalid = "The configuration option '%s' contains an invalid ". 24 | "value. This field requires a%s value%s."; 25 | 26 | // Verify that required fields are present 27 | foreach ($required as $key) 28 | if (!isset($config[$key])) { 29 | Logger::info(sprintf($not_defined, $key, escapeshellarg( 30 | StorageHandling::getPath($this, "config.json")))); 31 | return false; 32 | } 33 | 34 | // Autocomplete preload field if not given 35 | if (!isset($config["preload"])) 36 | $config["preload"] = 0; 37 | 38 | try { 39 | foreach(new RecursiveIteratorIterator(new RecursiveDirectoryIterator( 40 | $config["music"])) as $file => $obj) 41 | if (is_file($file) && is_readable($file)) 42 | $songs[] = $file; 43 | } catch (Exception $e) {} 44 | // Verify that there is music to play 45 | if (!is_dir($config["music"]) || !is_readable($config["music"]) || 46 | !isset($songs) || count($songs) < 1) { 47 | Logger::info("Could not find any music to play. Check that the ". 48 | "configuration option 'music' in the file at ".escapeshellarg( 49 | StorageHandling::getPath($this, "config.json"))." has the proper ". 50 | "path, there are files in the directory, and both the directory ". 51 | "and files are readable."); 52 | return false; 53 | } 54 | 55 | // Verify constraints of the bitrate variable 56 | if (!is_numeric($config["bitrate"]) || 57 | floatval($config["bitrate"]) != intval($config["bitrate"]) || 58 | $config["bitrate"] < 64 || $config["bitrate"] > 320) { 59 | Logger::info(sprintf($invalid, "bitrate", "n integer", " from 64 ". 60 | "to 320")); 61 | return false; 62 | } 63 | // Sanitize the field by grabbing its integer value 64 | $config["bitrate"] = intval($config["bitrate"]); 65 | 66 | // Verify constraints of the preload variable 67 | if (!is_numeric($config["preload"]) || 68 | floatval($config["preload"]) != intval($config["preload"]) || 69 | $config["preload"] < 0) { 70 | Logger::info(sprintf($invalid, "preload", "n integer", " above 0")); 71 | return false; 72 | } 73 | // Sanitize the field by grabbing its integer value 74 | $config["preload"] = intval($config["preload"]); 75 | 76 | // Verify constraints of the repeatfreq variable 77 | if (!is_numeric($config["repeatfreq"]) || 78 | floatval($config["repeatfreq"]) != intval($config["repeatfreq"]) || 79 | $config["repeatfreq"] < 0) { 80 | Logger::info(sprintf($invalid, "repeatfreq", "n integer", " above ". 81 | "0")); 82 | return false; 83 | } 84 | // Sanitize the field by grabbing its integer value 85 | $config["repeatfreq"] = intval($config["repeatfreq"]); 86 | 87 | // Verify constraints of the samplerate variable 88 | if (!is_numeric($config["samplerate"]) || 89 | floatval($config["samplerate"]) != intval($config["samplerate"]) || 90 | $config["samplerate"] < 44100) { 91 | Logger::info(sprintf($invalid, "samplerate", "n integer", " above ". 92 | "44100")); 93 | return false; 94 | } 95 | // Sanitize the field by grabbing its integer value 96 | $config["samplerate"] = intval($config["samplerate"]); 97 | 98 | // Sanitize the description, genre, name, and url fields 99 | $config["description"] = preg_replace("/[^\x20-\x7E]/", null, 100 | $config["description"]); 101 | $config["genre"] = preg_replace("/[^\x20-\x7E]/", null, 102 | $config["genre"]); 103 | $config["name"] = preg_replace("/[^\x20-\x7E]/", null, $config["name"]); 104 | $config["url"] = preg_replace("/[^\x20-\x7E]/", null, $config["url"]); 105 | 106 | // Verify the constraints of the description variable 107 | if (strlen($config["description"]) < 1) { 108 | Logger::info(sprintf($invalid, "description", " non-null", " that ". 109 | "contains human-readable ASCII characters")); 110 | return false; 111 | } 112 | 113 | // Verify the constraints of the genre variable 114 | if (strlen($config["genre"]) < 1) { 115 | Logger::info(sprintf($invalid, "genre", " non-null", " that ". 116 | "contains human-readable ASCII characters")); 117 | return false; 118 | } 119 | 120 | // Verify the constraints of the name variable 121 | if (strlen($config["name"]) < 1) { 122 | Logger::info(sprintf($invalid, "name", " non-null", " that contains ". 123 | "human-readable ASCII characters")); 124 | return false; 125 | } 126 | 127 | // Verify the constraints of the url variable 128 | if (strlen($config["url"]) < 1) { 129 | Logger::info(sprintf($invalid, "url", " non-null", " that contains ". 130 | "human-readable ASCII characters")); 131 | return false; 132 | } 133 | 134 | // Set a polling rate (in seconds) to broadcast data 135 | $config["rate"] = 0.01; 136 | 137 | // Calculate the burstint field 138 | $config["burstint"] = intval((($config["bitrate"] * 1000) / 8) * 139 | $config["rate"]); 140 | 141 | // Accept the config as given 142 | $this->config = $config; 143 | return true; 144 | } 145 | else { 146 | // Flush a default configuration file 147 | StorageHandling::saveFile($this, "config.json", json_encode(array( 148 | "bitrate" => 192, 149 | "description" => "Example station description.", 150 | "genre" => "Various Genres", 151 | "name" => "Untitled Station", 152 | "music" => "/var/private/music", 153 | "preload" => 3, 154 | "repeatfreq" => 30, 155 | "samplerate" => 44100, 156 | "url" => "http://example.org/" 157 | ), JSON_PRETTY_PRINT)); 158 | return $this->loadConfig(); 159 | } 160 | return false; 161 | } 162 | 163 | public function receiveRaw($name, $data) { 164 | $connection = $data[0]; 165 | $ex = $data[2]; 166 | $data = $data[1]; 167 | 168 | if ($connection->getType() == "1") { 169 | // Parse stream GET request for a mount point (unused) 170 | if (strtoupper($ex[0]) == "GET" && strtoupper($ex[2]) == "HTTP/1.1") 171 | $connection->setOption("stream", $ex[1]); 172 | // Parse metadata header which specifies if the client wants metadata 173 | if (strtolower($data) == "icy-metadata: 1") 174 | $connection->setOption("metadata", true); 175 | // Build response when end of request is reached 176 | if (trim($data) == null && $connection->getOption("stream") != false) { 177 | // Build an array of lines to send in the response header section 178 | $response = array( 179 | "HTTP/1.0 200 OK", 180 | "Content-Type: audio/mpeg", 181 | "Server: Radio-PHP (Modfwango v".__MODFWANGOVERSION__.")", 182 | "Cache-Control: no-cache", 183 | "icy-pub: -1", 184 | "icy-br: ". $this->config["bitrate"], 185 | "icy-description: ".$this->config["description"], 186 | "icy-genre: ". $this->config["genre"], 187 | "icy-name: ". $this->config["name"], 188 | "icy-url: ". $this->config["url"] 189 | ); 190 | // If the client wants metadata, tell the client that it will be 191 | // receiving it 192 | if ($connection->getOption("metadata") == true) 193 | $response[] = "icy-metaint: ".$this->config["burstint"]; 194 | 195 | // Flush the response header to the client 196 | $connection->send(trim(implode("\r\n", $response))."\r\n\r\n", false); 197 | 198 | // Set the given preload quantity as configured 199 | $connection->setOption("preload", intval($this->config["preload"] / 200 | $this->config["rate"])); 201 | 202 | // Signal the Stream module that this client is ready to receive the 203 | // broadcast 204 | $connection->setOption("ready", true); 205 | } 206 | } 207 | } 208 | 209 | public function isInstantiated() { 210 | // Intercept raw data from clients 211 | EventHandling::registerForEvent("rawEvent", $this, "receiveRaw"); 212 | // Allow this module to be loaded if the config was loaded successfully 213 | return $this->loadConfig(); 214 | } 215 | } 216 | ?> 217 | --------------------------------------------------------------------------------