<commit_before><?php
namespace AzuraCast\Radio\Backend;

use Doctrine\ORM\EntityManager;

class LiquidSoap extends BackendAbstract
{
    public function read()
    {
    }

    /**
     * Write configuration from Station object to the external service.
     * @return bool
     */
    public function write()
    {
        $settings = (array)$this->station->backend_config;

        $playlist_path = $this->station->getRadioPlaylistsDir();
        $config_path = $this->station->getRadioConfigDir();

        $ls_config = [
            '# WARNING! This file is automatically generated by AzuraCast.',
            '# Do not update it directly!',
            '',
            'set("init.daemon", false)',
            'set("init.daemon.pidfile.path","' . $config_path . '/liquidsoap.pid")',
            'set("log.file.path","' . $config_path . '/liquidsoap.log")',
            (APP_INSIDE_DOCKER ? 'set("log.stdout", true)' : ''),
            'set("server.telnet",true)',
            'set("server.telnet.bind_addr","'.(APP_INSIDE_DOCKER ? '0.0.0.0' : '127.0.0.1').'")',
            'set("server.telnet.port", ' . $this->_getTelnetPort() . ')',
            'set("server.telnet.reverse_dns",false)',
            'set("harbor.bind_addr","0.0.0.0")',
            'set("harbor.reverse_dns",false)',
            '',
            '# AutoDJ Next Song Script',
            'def azuracast_next_song() =',
            '  uri = get_process_lines("'.$this->_getApiUrlCommand('/api/internal/'.$this->station->id.'/nextsong').'")',
            '  uri = list.hd(uri)',
            '  log("AzuraCast Raw Response: #{uri}")',
            '  request.create(uri)',
            'end',
            '',
            '# DJ Authentication',
            'def dj_auth(user,password) =',
            '  log("Authenticating DJ: #{user}")',
            '  ret = get_process_lines("'.$this->_getApiUrlCommand('/api/internal/'.$this->station->id.'/auth', ['dj_user' => '#{user}', 'dj_password' => <PASSWORD>}']).'")',
            '  ret = list.hd(ret)',
            '  log("AzuraCast DJ Auth Response: #{ret}")',
            '  bool_of_string(ret)',
            'end',
            '',
            'live_enabled = ref false',
            '',
            'def live_connected(header) =',
            '    log("DJ Source connected!")',
            '    live_enabled := true',
            'end',
            '',
            'def live_disconnected() =',
            '   log("DJ Source disconnected!")',
            '   live_enabled := false',
            'end',
            '',
        ];

        // Clear out existing playlists directory.
        $current_playlists = array_diff(scandir($playlist_path), ['..', '.']);
        foreach ($current_playlists as $list) {
            @unlink($playlist_path . '/' . $list);
        }

        // Set up playlists using older format as a fallback.
        $ls_config[] = '# Fallback Playlists';

        $has_default_playlist = false;
        $playlist_objects = [];

        foreach ($this->station->playlists as $playlist_raw) {
            /** @var \Entity\StationPlaylist $playlist_raw */
            if (!$playlist_raw->is_enabled) {
                continue;
            }
            if ($playlist_raw->type == 'default') {
                $has_default_playlist = true;
            }

            $playlist_objects[] = $playlist_raw;
        }

        // Create a new default playlist if one doesn't exist.
        if (!$has_default_playlist) {

            $this->log(_('No default playlist existed for the station, so one was automatically created. You can add songs to it from the "Media" page.'),'error');

            // Auto-create an empty default playlist.
            $default_playlist = new \Entity\StationPlaylist();
            $default_playlist->station = $this->station;
            $default_playlist->name = 'default';

            /** @var EntityManager $em */
            $em = $this->di['em'];
            $em->persist($default_playlist);
            $em->flush();

            $playlist_objects[] = $default_playlist;
        }

        $playlists_by_type = [];
        $playlists = [];

        foreach ($playlist_objects as $playlist_raw) {

            $playlist_file_contents = $playlist_raw->export('m3u', true);

            $playlist = $playlist_raw->toArray($this->di['em']);
            $playlist['var_name'] = 'playlist_' . $playlist_raw->getShortName();
            $playlist['file_path'] = $playlist_path . '/' . $playlist['var_name'] . '.m3u';

            file_put_contents($playlist['file_path'], $playlist_file_contents);

            $ls_config[] = $playlist['var_name'] . ' = playlist(reload_mode="watch","' . $playlist['file_path'] . '")';

            $playlist_type = $playlist['type'] ?: 'default';
            $playlists_by_type[$playlist_type][] = $playlist;
            $playlists[] = $playlist;
        }

        $ls_config[] = '';

        // Create fallback playlist based on all default playlists.
        $playlist_weights = [];
        $playlist_vars = [];
        foreach ($playlists_by_type['default'] as $playlist) {
            $playlist_weights[] = $playlist['weight'];
            $playlist_vars[] = $playlist['var_name'];
        }

        $ls_config[] = 'playlists = random(weights=[' . implode(', ', $playlist_weights) . '], [' . implode(', ',
                $playlist_vars) . ']);';

        $ls_config[] = 'dynamic = request.dynamic(id="azuracast_next_song", azuracast_next_song)';
        $ls_config[] = 'dynamic = cue_cut(id="azuracast_next_song_cued", dynamic)';
        $ls_config[] = 'radio = fallback(track_sensitive = false, [dynamic, playlists, blank(duration=2.)])';
        $ls_config[] = '';

        // Add harbor live.
        $harbor_params = [
            '"/"',
            'port='.$this->getStreamPort(),
            'user="shoutcast"',
            'auth=dj_auth',
            'icy=true',
            'max=30.',
            'buffer=5.',
            'on_connect=live_connected',
            'on_disconnect=live_disconnected',
        ];

        $ls_config[] = 'live = audio_to_stereo(input.harbor('.implode(', ', $harbor_params).'))';
        $ls_config[] = 'ignore(output.dummy(live, fallible=true))';
        $ls_config[] = 'live = fallback(track_sensitive=false, [live, blank(duration=2.)])';

        $ls_config[] = '';
        $ls_config[] = 'radio = switch(id="live_switch", track_sensitive=false, [({!live_enabled}, live), ({true}, radio)])';
        $ls_config[] = '';

        // Crossfading
        $crossfade = (int)($settings['crossfade'] ?? 2);
        if ($crossfade > 0) {
            $start_next = round($crossfade * 1.5);
            $ls_config[] = '# Crossfading';
            $ls_config[] = 'radio = crossfade(start_next=' . $start_next . '.,fade_out=' . $crossfade . '.,fade_in=' . $crossfade . '.,radio)';
            $ls_config[] = '';
        }

        if (!empty($settings['custom_config'])) {
            $ls_config[] = '# Custom Configuration (Specified in Station Profile)';
            $ls_config[] = $settings['custom_config'];
            $ls_config[] = '';
        }

        $ls_config[] = '# Outbound Broadcast';

        // Configure the outbound broadcast.
        $fe_settings = (array)$this->station->frontend_config;

        $broadcast_port = $fe_settings['port'];
        $broadcast_source_pw = $fe_settings['source_pw'];

        $settings_repo = $this->di['em']->getRepository('Entity\Settings');
        $base_url = $settings_repo->getSetting('base_url', 'localhost');

        switch ($this->station->frontend_type) {
            case 'remote':
                $this->log(_('You cannot use an AutoDJ with a remote frontend. Please change the frontend type or update the backend to be "Disabled".'),
                    'error');

                return false;
                break;

            case 'shoutcast2':
                $i = 0;
                foreach ($this->station->mounts as $mount_row) {
                    $i++;

                    if (!$mount_row->enable_autodj) {
                        continue;
                    }

                    $format = strtolower($mount_row->autodj_format ?: 'mp3');
                    $bitrate = $mount_row->autodj_bitrate ?: 128;

                    if ($format == 'aac') {
                        $output_format = '%fdkaac(channels=2, samplerate=44100, bitrate='.(int)$bitrate.', afterburner=true, aot="mpeg4_he_aac_v2", transmux="adts", sbr_mode=true)';
                    } else {
                        $output_format = '%mp3.cbr(samplerate=44100,stereo=true,bitrate=' . (int)$bitrate . ')';
                    }

                    $output_params = [
                        $output_format, // Required output format (%mp3 etc)
                        'id="radio_out_' . $i . '"',
                        'host = "localhost"',
                        'port = ' . ($broadcast_port),
                        'password = "' . $broadcast_source_pw . ':#'.$i.'"',
                        'name = "' . $this->_cleanUpString($this->station->name) . '"',
                        'url = "' . $this->_cleanUpString($this->station->url ?: $base_url) . '"',
                        'public = '.(($mount_row->is_public) ? 'true' : 'false'),
                        'radio', // Required
                    ];
                    $ls_config[] = 'output.shoutcast(' . implode(', ', $output_params) . ')';
                }
                break;

            case 'icecast':
            default:
                $i = 0;
                foreach ($this->station->mounts as $mount_row) {
                    $i++;

                    if (!$mount_row->enable_autodj) {
                        continue;
                    }

                    $format = strtolower($mount_row->autodj_format ?: 'mp3');
                    $bitrate = $mount_row->autodj_bitrate ?: 128;

                    if ($format == 'ogg') {
                        $output_format = '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . (int)$bitrate . ')';
                    } else {
                        $output_format = '%mp3.cbr(samplerate=44100,stereo=true,bitrate=' . (int)$bitrate . ')';
                    }

                    if (!empty($output_format)) {
                        $output_params = [
                            $output_format, // Required output format (%mp3 or %ogg)
                            'id="radio_out_' . $i . '"',
                            'host = "localhost"',
                            'port = ' . $broadcast_port,
                            'password = "' . $<PASSWORD> . '"',
                            'name = "' . $this->_cleanUpString($this->station->name) . '"',
                            'description = "' . $this->_cleanUpString($this->station->description) . '"',
                            'url = "' . $this->_cleanUpString($this->station->url ?: $base_url) . '"',
                            'mount = "' . $mount_row->name . '"',
                            'public = '.(($mount_row->is_public) ? 'true' : 'false'),
                            'radio', // Required
                        ];
                        $ls_config[] = 'output.icecast(' . implode(', ', $output_params) . ')';
                    }
                }
                break;
        }

        $ls_config_contents = implode("\n", $ls_config);

        $ls_config_path = $config_path . '/liquidsoap.liq';
        file_put_contents($ls_config_path, $ls_config_contents);

        return true;
    }

    protected function _getApiUrlCommand($endpoint, $params = [])
    {
        $params = (array)$params;
        $params['api_auth'] = $this->_getApiPassword();

        $base_url = (APP_INSIDE_DOCKER) ? 'http://nginx' : 'http://localhost';

        $curl_request = 'curl -s --request POST --url '.$base_url.$endpoint;
        foreach($params as $param_key => $param_val) {
            $curl_request .= ' --form '.$param_key.'='.$param_val;
        }
        return $curl_request;
    }

    protected function _cleanUpString($string)
    {
        return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string);
    }

    protected function _getTime($time_code)
    {
        $hours = floor($time_code / 100);
        $mins = $time_code % 100;

        $system_time_zone = \App\Utilities::get_system_time_zone();
        $system_tz = new \DateTimeZone($system_time_zone);
        $system_dt = new \DateTime('now', $system_tz);
        $system_offset = $system_tz->getOffset($system_dt);

        $app_tz = new \DateTimeZone(date_default_timezone_get());
        $app_dt = new \DateTime('now', $app_tz);
        $app_offset = $app_tz->getOffset($app_dt);

        $offset = $system_offset - $app_offset;
        $offset_hours = floor($offset / 3600);

        $hours += $offset_hours;

        $hours = $hours % 24;
        if ($hours < 0) {
            $hours += 24;
        }

        return $hours . 'h' . $mins . 'm';
    }

    /**
     * Generate a stream-unique API password.
     *
     * @return string
     */
    public function _getApiPassword()
    {
        $be_settings = (array)$this->station->backend_config;

        if (empty($be_settings['api_password'])) {
            $be_settings['api_password'] = bin2hex(random_bytes(50));

            $em = $this->di['em'];

            $this->station->backend_config = $be_settings;
            $em->persist($this->station);
            $em->flush();
        }

        return $be_settings['api_password'];
    }

    /**
     * Validate the API password used for internal API authentication.
     *
     * @param $password
     * @return bool
     */
    public function validateApiPassword($password)
    {
        return hash_equals($password, $this->_getApiPassword());
    }

    public function getCommand()
    {
        $user_base = realpath(APP_INCLUDE_ROOT.'/..');
        $config_path = $this->station->getRadioConfigDir() . '/liquidsoap.liq';

        return $user_base.'/.opam/system/bin/liquidsoap --errors-as-warnings ' . $config_path;
    }

    public function skip()
    {
        return $this->command('radio_out_1.skip');
    }

    public function command($command_str)
    {
        $fp = stream_socket_client('tcp://'.(APP_INSIDE_DOCKER ? 'stations' : 'localhost').':' . $this->_getTelnetPort(), $errno, $errstr, 20);

        if (!$fp) {
            throw new \App\Exception('Telnet failure: ' . $errstr . ' (' . $errno . ')');
        }

        fwrite($fp, str_replace(["\\'", '&amp;'], ["'", '&'], urldecode($command_str)) . "\nquit\n");

        $response = [];
        while (!feof($fp)) {
            $response[] = trim(fgets($fp, 1024));
        }

        fclose($fp);

        return $response;
    }

    public function getStreamPort()
    {
        return (8000 + (($this->station->id - 1) * 10) + 5);
    }

    protected function _getTelnetPort()
    {
        return (8000 + (($this->station->id - 1) * 10) + 4);
    }

    /*
     * Static Functions
     */

    public static function isInstalled()
    {
        $user_base = realpath(APP_INCLUDE_ROOT.'/..');
        return (APP_INSIDE_DOCKER || file_exists($user_base.'/.opam/system/bin/liquidsoap'));
    }
}<commit_msg>Minor stylistic update to the new playlist creation message. [skip ci]<commit_after><?php
namespace AzuraCast\Radio\Backend;

use Doctrine\ORM\EntityManager;

class LiquidSoap extends BackendAbstract
{
    public function read()
    {
    }

    /**
     * Write configuration from Station object to the external service.
     * @return bool
     */
    public function write()
    {
        $settings = (array)$this->station->backend_config;

        $playlist_path = $this->station->getRadioPlaylistsDir();
        $config_path = $this->station->getRadioConfigDir();

        $ls_config = [
            '# WARNING! This file is automatically generated by AzuraCast.',
            '# Do not update it directly!',
            '',
            'set("init.daemon", false)',
            'set("init.daemon.pidfile.path","' . $config_path . '/liquidsoap.pid")',
            'set("log.file.path","' . $config_path . '/liquidsoap.log")',
            (APP_INSIDE_DOCKER ? 'set("log.stdout", true)' : ''),
            'set("server.telnet",true)',
            'set("server.telnet.bind_addr","'.(APP_INSIDE_DOCKER ? '0.0.0.0' : '127.0.0.1').'")',
            'set("server.telnet.port", ' . $this->_getTelnetPort() . ')',
            'set("server.telnet.reverse_dns",false)',
            'set("harbor.bind_addr","0.0.0.0")',
            'set("harbor.reverse_dns",false)',
            '',
            '# AutoDJ Next Song Script',
            'def azuracast_next_song() =',
            '  uri = get_process_lines("'.$this->_getApiUrlCommand('/api/internal/'.$this->station->id.'/nextsong').'")',
            '  uri = list.hd(uri)',
            '  log("AzuraCast Raw Response: #{uri}")',
            '  request.create(uri)',
            'end',
            '',
            '# DJ Authentication',
            'def dj_auth(user,password) =',
            '  log("Authenticating DJ: #{user}")',
            '  ret = get_process_lines("'.$this->_getApiUrlCommand('/api/internal/'.$this->station->id.'/auth', ['dj_user' => '#{user}', 'dj_password' => '#{password}']).'")',
            '  ret = list.hd(ret)',
            '  log("AzuraCast DJ Auth Response: #{ret}")',
            '  bool_of_string(ret)',
            'end',
            '',
            'live_enabled = ref false',
            '',
            'def live_connected(header) =',
            '    log("DJ Source connected!")',
            '    live_enabled := true',
            'end',
            '',
            'def live_disconnected() =',
            '   log("DJ Source disconnected!")',
            '   live_enabled := false',
            'end',
            '',
        ];

        // Clear out existing playlists directory.
        $current_playlists = array_diff(scandir($playlist_path), ['..', '.']);
        foreach ($current_playlists as $list) {
            @unlink($playlist_path . '/' . $list);
        }

        // Set up playlists using older format as a fallback.
        $ls_config[] = '# Fallback Playlists';

        $has_default_playlist = false;
        $playlist_objects = [];

        foreach ($this->station->playlists as $playlist_raw) {
            /** @var \Entity\StationPlaylist $playlist_raw */
            if (!$playlist_raw->is_enabled) {
                continue;
            }
            if ($playlist_raw->type == 'default') {
                $has_default_playlist = true;
            }

            $playlist_objects[] = $playlist_raw;
        }

        // Create a new default playlist if one doesn't exist.
        if (!$has_default_playlist) {

            $this->log(_('No default playlist existed for the station, so one was automatically created. You can add songs to it from the "Media" page.'),'info');

            // Auto-create an empty default playlist.
            $default_playlist = new \Entity\StationPlaylist();
            $default_playlist->station = $this->station;
            $default_playlist->name = 'default';

            /** @var EntityManager $em */
            $em = $this->di['em'];
            $em->persist($default_playlist);
            $em->flush();

            $playlist_objects[] = $default_playlist;
        }

        $playlists_by_type = [];
        $playlists = [];

        foreach ($playlist_objects as $playlist_raw) {

            $playlist_file_contents = $playlist_raw->export('m3u', true);

            $playlist = $playlist_raw->toArray($this->di['em']);
            $playlist['var_name'] = 'playlist_' . $playlist_raw->getShortName();
            $playlist['file_path'] = $playlist_path . '/' . $playlist['var_name'] . '.m3u';

            file_put_contents($playlist['file_path'], $playlist_file_contents);

            $ls_config[] = $playlist['var_name'] . ' = playlist(reload_mode="watch","' . $playlist['file_path'] . '")';

            $playlist_type = $playlist['type'] ?: 'default';
            $playlists_by_type[$playlist_type][] = $playlist;
            $playlists[] = $playlist;
        }

        $ls_config[] = '';

        // Create fallback playlist based on all default playlists.
        $playlist_weights = [];
        $playlist_vars = [];
        foreach ($playlists_by_type['default'] as $playlist) {
            $playlist_weights[] = $playlist['weight'];
            $playlist_vars[] = $playlist['var_name'];
        }

        $ls_config[] = 'playlists = random(weights=[' . implode(', ', $playlist_weights) . '], [' . implode(', ',
                $playlist_vars) . ']);';

        $ls_config[] = 'dynamic = request.dynamic(id="azuracast_next_song", azuracast_next_song)';
        $ls_config[] = 'dynamic = cue_cut(id="azuracast_next_song_cued", dynamic)';
        $ls_config[] = 'radio = fallback(track_sensitive = false, [dynamic, playlists, blank(duration=2.)])';
        $ls_config[] = '';

        // Add harbor live.
        $harbor_params = [
            '"/"',
            'port='.$this->getStreamPort(),
            'user="shoutcast"',
            'auth=dj_auth',
            'icy=true',
            'max=30.',
            'buffer=5.',
            'on_connect=live_connected',
            'on_disconnect=live_disconnected',
        ];

        $ls_config[] = 'live = audio_to_stereo(input.harbor('.implode(', ', $harbor_params).'))';
        $ls_config[] = 'ignore(output.dummy(live, fallible=true))';
        $ls_config[] = 'live = fallback(track_sensitive=false, [live, blank(duration=2.)])';

        $ls_config[] = '';
        $ls_config[] = 'radio = switch(id="live_switch", track_sensitive=false, [({!live_enabled}, live), ({true}, radio)])';
        $ls_config[] = '';

        // Crossfading
        $crossfade = (int)($settings['crossfade'] ?? 2);
        if ($crossfade > 0) {
            $start_next = round($crossfade * 1.5);
            $ls_config[] = '# Crossfading';
            $ls_config[] = 'radio = crossfade(start_next=' . $start_next . '.,fade_out=' . $crossfade . '.,fade_in=' . $crossfade . '.,radio)';
            $ls_config[] = '';
        }

        if (!empty($settings['custom_config'])) {
            $ls_config[] = '# Custom Configuration (Specified in Station Profile)';
            $ls_config[] = $settings['custom_config'];
            $ls_config[] = '';
        }

        $ls_config[] = '# Outbound Broadcast';

        // Configure the outbound broadcast.
        $fe_settings = (array)$this->station->frontend_config;

        $broadcast_port = $fe_settings['port'];
        $broadcast_source_pw = $fe_settings['source_pw'];

        $settings_repo = $this->di['em']->getRepository('Entity\Settings');
        $base_url = $settings_repo->getSetting('base_url', 'localhost');

        switch ($this->station->frontend_type) {
            case 'remote':
                $this->log(_('You cannot use an AutoDJ with a remote frontend. Please change the frontend type or update the backend to be "Disabled".'),
                    'error');

                return false;
                break;

            case 'shoutcast2':
                $i = 0;
                foreach ($this->station->mounts as $mount_row) {
                    $i++;

                    if (!$mount_row->enable_autodj) {
                        continue;
                    }

                    $format = strtolower($mount_row->autodj_format ?: 'mp3');
                    $bitrate = $mount_row->autodj_bitrate ?: 128;

                    if ($format == 'aac') {
                        $output_format = '%fdkaac(channels=2, samplerate=44100, bitrate='.(int)$bitrate.', afterburner=true, aot="mpeg4_he_aac_v2", transmux="adts", sbr_mode=true)';
                    } else {
                        $output_format = '%mp3.cbr(samplerate=44100,stereo=true,bitrate=' . (int)$bitrate . ')';
                    }

                    $output_params = [
                        $output_format, // Required output format (%mp3 etc)
                        'id="radio_out_' . $i . '"',
                        'host = "localhost"',
                        'port = ' . ($broadcast_port),
                        'password = "' . $broadcast_source_pw . ':#'.$i.'"',
                        'name = "' . $this->_cleanUpString($this->station->name) . '"',
                        'url = "' . $this->_cleanUpString($this->station->url ?: $base_url) . '"',
                        'public = '.(($mount_row->is_public) ? 'true' : 'false'),
                        'radio', // Required
                    ];
                    $ls_config[] = 'output.shoutcast(' . implode(', ', $output_params) . ')';
                }
                break;

            case 'icecast':
            default:
                $i = 0;
                foreach ($this->station->mounts as $mount_row) {
                    $i++;

                    if (!$mount_row->enable_autodj) {
                        continue;
                    }

                    $format = strtolower($mount_row->autodj_format ?: 'mp3');
                    $bitrate = $mount_row->autodj_bitrate ?: 128;

                    if ($format == 'ogg') {
                        $output_format = '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . (int)$bitrate . ')';
                    } else {
                        $output_format = '%mp3.cbr(samplerate=44100,stereo=true,bitrate=' . (int)$bitrate . ')';
                    }

                    if (!empty($output_format)) {
                        $output_params = [
                            $output_format, // Required output format (%mp3 or %ogg)
                            'id="radio_out_' . $i . '"',
                            'host = "localhost"',
                            'port = ' . $broadcast_port,
                            'password = "' . <PASSWORD> . '"',
                            'name = "' . $this->_cleanUpString($this->station->name) . '"',
                            'description = "' . $this->_cleanUpString($this->station->description) . '"',
                            'url = "' . $this->_cleanUpString($this->station->url ?: $base_url) . '"',
                            'mount = "' . $mount_row->name . '"',
                            'public = '.(($mount_row->is_public) ? 'true' : 'false'),
                            'radio', // Required
                        ];
                        $ls_config[] = 'output.icecast(' . implode(', ', $output_params) . ')';
                    }
                }
                break;
        }

        $ls_config_contents = implode("\n", $ls_config);

        $ls_config_path = $config_path . '/liquidsoap.liq';
        file_put_contents($ls_config_path, $ls_config_contents);

        return true;
    }

    protected function _getApiUrlCommand($endpoint, $params = [])
    {
        $params = (array)$params;
        $params['api_auth'] = $this->_getApiPassword();

        $base_url = (APP_INSIDE_DOCKER) ? 'http://nginx' : 'http://localhost';

        $curl_request = 'curl -s --request POST --url '.$base_url.$endpoint;
        foreach($params as $param_key => $param_val) {
            $curl_request .= ' --form '.$param_key.'='.$param_val;
        }
        return $curl_request;
    }

    protected function _cleanUpString($string)
    {
        return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string);
    }

    protected function _getTime($time_code)
    {
        $hours = floor($time_code / 100);
        $mins = $time_code % 100;

        $system_time_zone = \App\Utilities::get_system_time_zone();
        $system_tz = new \DateTimeZone($system_time_zone);
        $system_dt = new \DateTime('now', $system_tz);
        $system_offset = $system_tz->getOffset($system_dt);

        $app_tz = new \DateTimeZone(date_default_timezone_get());
        $app_dt = new \DateTime('now', $app_tz);
        $app_offset = $app_tz->getOffset($app_dt);

        $offset = $system_offset - $app_offset;
        $offset_hours = floor($offset / 3600);

        $hours += $offset_hours;

        $hours = $hours % 24;
        if ($hours < 0) {
            $hours += 24;
        }

        return $hours . 'h' . $mins . 'm';
    }

    /**
     * Generate a stream-unique API password.
     *
     * @return string
     */
    public function _getApiPassword()
    {
        $be_settings = (array)$this->station->backend_config;

        if (empty($be_settings['api_password'])) {
            $be_settings['api_password'] = <PASSWORD>2<PASSWORD>(random_bytes(50));

            $em = $this->di['em'];

            $this->station->backend_config = $be_settings;
            $em->persist($this->station);
            $em->flush();
        }

        return $be_settings['api_password'];
    }

    /**
     * Validate the API password used for internal API authentication.
     *
     * @param $password
     * @return bool
     */
    public function validateApiPassword($password)
    {
        return hash_equals($password, $this->_getApiPassword());
    }

    public function getCommand()
    {
        $user_base = realpath(APP_INCLUDE_ROOT.'/..');
        $config_path = $this->station->getRadioConfigDir() . '/liquidsoap.liq';

        return $user_base.'/.opam/system/bin/liquidsoap --errors-as-warnings ' . $config_path;
    }

    public function skip()
    {
        return $this->command('radio_out_1.skip');
    }

    public function command($command_str)
    {
        $fp = stream_socket_client('tcp://'.(APP_INSIDE_DOCKER ? 'stations' : 'localhost').':' . $this->_getTelnetPort(), $errno, $errstr, 20);

        if (!$fp) {
            throw new \App\Exception('Telnet failure: ' . $errstr . ' (' . $errno . ')');
        }

        fwrite($fp, str_replace(["\\'", '&amp;'], ["'", '&'], urldecode($command_str)) . "\nquit\n");

        $response = [];
        while (!feof($fp)) {
            $response[] = trim(fgets($fp, 1024));
        }

        fclose($fp);

        return $response;
    }

    public function getStreamPort()
    {
        return (8000 + (($this->station->id - 1) * 10) + 5);
    }

    protected function _getTelnetPort()
    {
        return (8000 + (($this->station->id - 1) * 10) + 4);
    }

    /*
     * Static Functions
     */

    public static function isInstalled()
    {
        $user_base = realpath(APP_INCLUDE_ROOT.'/..');
        return (APP_INSIDE_DOCKER || file_exists($user_base.'/.opam/system/bin/liquidsoap'));
    }
}
{'fraction_non_alphanumeric': 0.16586664913203578, 'fraction_numerical': 0.006904260915307733, 'mean_word_length': 1.8174323823638385, 'pattern_counts': {'":': 0, '<': 12, '<?xml version=': 0, '>': 186, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 6, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 12, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '4814951', 'n_tokens_mistral': 9406, 'n_tokens_neox': 8926, 'n_words': 1536}