em->find(Entity\StationPlaylist::class, $message->playlist_id); if ($playlist instanceof Entity\StationPlaylist) { $this->writePlaylistFile($playlist); } } } /** * @return mixed[] */ public static function getSubscribedEvents(): array { return [ WriteLiquidsoapConfiguration::class => [ ['writeHeaderFunctions', 35], ['writePlaylistConfiguration', 30], ['writeCrossfadeConfiguration', 25], ['writeHarborConfiguration', 20], ['writePreBroadcastConfiguration', 10], ['writeLocalBroadcastConfiguration', 5], ['writeRemoteBroadcastConfiguration', 0], ['writePostBroadcastConfiguration', -5], ], ]; } public function writeCustomConfigurationSection(WriteLiquidsoapConfiguration $event, string $sectionName): void { if ($event->isForEditing()) { $divider = self::getDividerString(); $event->appendLines( [ $divider . $sectionName . $divider, ] ); return; } $settings = $this->settingsRepo->readSettings(); if (!$settings->getEnableAdvancedFeatures()) { return; } $settings = $event->getStation()->getBackendConfig(); if (!empty($settings[$sectionName])) { $event->appendLines( [ '# Custom Configuration (Specified in Station Profile)', $settings[$sectionName], ] ); } } public static function getDividerString(): string { return chr(7); } public function writeHeaderFunctions(WriteLiquidsoapConfiguration $event): void { if (!$event->isForEditing()) { $event->prependLines( [ '# WARNING! This file is automatically generated by AzuraCast.', '# Do not update it directly!', ] ); } $this->writeCustomConfigurationSection($event, self::CUSTOM_TOP); $station = $event->getStation(); $configDir = $station->getRadioConfigDir(); $pidfile = $configDir . DIRECTORY_SEPARATOR . 'liquidsoap.pid'; $telnetBindAddr = match (true) { $this->environment->isDockerStandalone() => '127.0.0.1', $this->environment->isDocker() => '0.0.0.0', default => '127.0.0.1', }; $telnetPort = $this->liquidsoap->getTelnetPort($station); $stationTz = self::cleanUpString($station->getTimezone()); $stationApiAuth = self::cleanUpString($station->getAdapterApiKey()); $stationApiUrl = self::cleanUpString( (string)$this->environment->getUriToWeb() ->withPath('/api/internal/' . $station->getId()) ); $event->appendBlock( <<getBackendConfig(); $perfMode = $backendConfig->getPerformanceModeEnum(); if ($perfMode !== Entity\Enums\StationBackendPerformanceModes::Disabled) { $gcSpaceOverhead = match ($backendConfig->getPerformanceModeEnum()) { Entity\Enums\StationBackendPerformanceModes::LessMemory => 20, Entity\Enums\StationBackendPerformanceModes::LessCpu => 140, Entity\Enums\StationBackendPerformanceModes::Balanced => 80, Entity\Enums\StationBackendPerformanceModes::Disabled => 0, }; $event->appendBlock( <<getStation(); $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_PLAYLISTS); // Clear out existing playlists directory. $fsPlaylists = (new StationFilesystems($station))->getPlaylistsFilesystem(); foreach ($fsPlaylists->listContents('', false) as $file) { /** @var StorageAttributes $file */ if ($file->isDir()) { $fsPlaylists->deleteDirectory($file->path()); } else { $fsPlaylists->delete($file->path()); } } // Set up playlists using older format as a fallback. $playlistObjects = []; foreach ($station->getPlaylists() as $playlistRaw) { /** @var Entity\StationPlaylist $playlistRaw */ if (!$playlistRaw->getIsEnabled()) { continue; } $playlistObjects[] = $playlistRaw; } $playlistVarNames = []; $genPlaylistWeights = []; $genPlaylistVars = []; $specialPlaylists = [ 'once_per_x_songs' => [ '# Once per x Songs Playlists', ], 'once_per_x_minutes' => [ '# Once per x Minutes Playlists', ], ]; $scheduleSwitches = []; $scheduleSwitchesInterrupting = []; foreach ($playlistObjects as $playlist) { /** @var Entity\StationPlaylist $playlist */ $playlistVarName = self::cleanUpVarName('playlist_' . $playlist->getShortName()); if (in_array($playlistVarName, $playlistVarNames, true)) { $playlistVarName .= '_' . $playlist->getId(); } $playlistVarNames[] = $playlistVarName; $playlistConfigLines = []; if (Entity\Enums\PlaylistSources::Songs === $playlist->getSourceEnum()) { $playlistFilePath = $this->writePlaylistFile($playlist, false); if (!$playlistFilePath) { continue; } $playlistParams = [ 'id="' . self::cleanUpString($playlistVarName) . '"', 'mime_type="audio/x-mpegurl"', ]; $playlistMode = match ($playlist->getOrderEnum()) { Entity\Enums\PlaylistOrders::Sequential => 'normal', Entity\Enums\PlaylistOrders::Shuffle => 'randomize', Entity\Enums\PlaylistOrders::Random => 'random' }; $playlistParams[] = 'mode="' . $playlistMode . '"'; if ($playlist->backendLoopPlaylistOnce()) { $playlistParams[] = 'reload_mode="never"'; } else { $playlistParams[] = 'reload_mode="watch"'; } $playlistParams[] = '"' . $playlistFilePath . '"'; $playlistConfigLines[] = $playlistVarName . ' = playlist(' . implode(',', $playlistParams) . ')'; if ($playlist->backendMerge()) { $playlistConfigLines[] = $playlistVarName . ' = merge_tracks(id="merge_' . self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')'; } $playlistConfigLines[] = $playlistVarName . ' = cue_cut(id="cue_' . self::cleanUpString($playlistVarName) . '", ' . $playlistVarName . ')'; } else { switch ($playlist->getRemoteTypeEnum()) { case Entity\Enums\PlaylistRemoteTypes::Playlist: $playlistFunc = 'playlist("' . self::cleanUpString($playlist->getRemoteUrl()) . '")'; $playlistConfigLines[] = $playlistVarName . ' = ' . $playlistFunc; break; case Entity\Enums\PlaylistRemoteTypes::Stream: default: $remote_url = $playlist->getRemoteUrl(); if (null !== $remote_url) { $buffer = $playlist->getRemoteBuffer(); $buffer = ($buffer < 1) ? Entity\StationPlaylist::DEFAULT_REMOTE_BUFFER : $buffer; $playlistConfigLines[] = $playlistVarName . ' = mksafe(buffer(buffer=' . $buffer . '., input.http(max_buffer=' . $buffer . '., "' . self::cleanUpString( $remote_url ) . '")))'; } break; } } if ($playlist->getIsJingle()) { $playlistConfigLines[] = $playlistVarName . ' = drop_metadata(' . $playlistVarName . ')'; } if (Entity\Enums\PlaylistTypes::Advanced === $playlist->getTypeEnum()) { $playlistConfigLines[] = 'ignore(' . $playlistVarName . ')'; } $event->appendLines($playlistConfigLines); if ($playlist->backendPlaySingleTrack()) { $playlistVarName = 'once(' . $playlistVarName . ')'; } $scheduleItems = $playlist->getScheduleItems(); switch ($playlist->getTypeEnum()) { case Entity\Enums\PlaylistTypes::Standard: if ($scheduleItems->count() > 0) { foreach ($scheduleItems as $scheduleItem) { $play_time = $this->getScheduledPlaylistPlayTime($event, $scheduleItem); $schedule_timing = '({ ' . $play_time . ' }, ' . $playlistVarName . ')'; if ($playlist->backendInterruptOtherSongs()) { $scheduleSwitchesInterrupting[] = $schedule_timing; } else { $scheduleSwitches[] = $schedule_timing; } } } else { $genPlaylistWeights[] = $playlist->getWeight(); $genPlaylistVars[] = $playlistVarName; } break; case Entity\Enums\PlaylistTypes::OncePerXSongs: case Entity\Enums\PlaylistTypes::OncePerXMinutes: if (Entity\Enums\PlaylistTypes::OncePerXSongs === $playlist->getTypeEnum()) { $playlistScheduleVar = 'rotate(weights=[1,' . $playlist->getPlayPerSongs() . '], [' . $playlistVarName . ', radio])'; } else { $delaySeconds = $playlist->getPlayPerMinutes() * 60; $delayTrackSensitive = $playlist->backendInterruptOtherSongs() ? 'false' : 'true'; $playlistScheduleVar = 'fallback(track_sensitive=' . $delayTrackSensitive . ', [delay(' . $delaySeconds . '., ' . $playlistVarName . '), radio])'; } if ($scheduleItems->count() > 0) { foreach ($scheduleItems as $scheduleItem) { $play_time = $this->getScheduledPlaylistPlayTime($event, $scheduleItem); $schedule_timing = '({ ' . $play_time . ' }, ' . $playlistScheduleVar . ')'; if ($playlist->backendInterruptOtherSongs()) { $scheduleSwitchesInterrupting[] = $schedule_timing; } else { $scheduleSwitches[] = $schedule_timing; } } } else { $specialPlaylists[$playlist->getType()][] = 'radio = ' . $playlistScheduleVar; } break; case Entity\Enums\PlaylistTypes::OncePerHour: $minutePlayTime = $playlist->getPlayPerHourMinute() . 'm'; if ($scheduleItems->count() > 0) { foreach ($scheduleItems as $scheduleItem) { $playTime = '(' . $minutePlayTime . ') and (' . $this->getScheduledPlaylistPlayTime($event, $scheduleItem) . ')'; $schedule_timing = '({ ' . $playTime . ' }, ' . $playlistVarName . ')'; if ($playlist->backendInterruptOtherSongs()) { $scheduleSwitchesInterrupting[] = $schedule_timing; } else { $scheduleSwitches[] = $schedule_timing; } } } else { $schedule_timing = '({ ' . $minutePlayTime . ' }, ' . $playlistVarName . ')'; if ($playlist->backendInterruptOtherSongs()) { $scheduleSwitchesInterrupting[] = $schedule_timing; } else { $scheduleSwitches[] = $schedule_timing; } } break; } } // Build "default" type playlists. $event->appendLines( [ '# Standard Playlists', sprintf( 'radio = random(id="standard_playlists", weights=[%s], [%s])', implode(', ', $genPlaylistWeights), implode(', ', $genPlaylistVars) ), ] ); if (!empty($scheduleSwitches)) { $event->appendLines(['# Standard Schedule Switches']); // Chunk scheduled switches to avoid hitting the max amount of playlists in a switch() foreach (array_chunk($scheduleSwitches, 168, true) as $scheduleSwitchesChunk) { $scheduleSwitchesChunk[] = '({true}, radio)'; $event->appendLines( [ sprintf( 'radio = switch(id="schedule_switch", track_sensitive=true, [ %s ])', implode(', ', $scheduleSwitchesChunk) ), ] ); } } // Add in special playlists if necessary. foreach ($specialPlaylists as $playlistConfigLines) { if (count($playlistConfigLines) > 1) { $event->appendLines($playlistConfigLines); } } if (!$station->useManualAutoDJ()) { $event->appendBlock( <<< EOF # AutoDJ Next Song Script def autodj_next_song() = response = azuracast_api_call( "nextsong", "" ) if (response.success != true) or (response.data == "") or (string.match(pattern="Error", response.data)) then null() else r = request.create(response.data) if request.resolve(r) then r else null() end end end # Delayed ping for AutoDJ Next Song def wait_for_next_song(autodj) autodj_ping_attempts := !autodj_ping_attempts + 1 if source.is_ready(autodj) then log("AutoDJ is ready!") autodj_is_loading := false -1.0 elsif !autodj_ping_attempts > 200 then log("AutoDJ could not be initialized within the specified timeout.") autodj_is_loading := false -1.0 else 0.5 end end dynamic = request.dynamic(id="next_song", timeout=20., retry_delay=2., autodj_next_song) dynamic = cue_cut(id="cue_next_song", dynamic) dynamic_startup = fallback( id = "dynamic_startup", track_sensitive = false, [ dynamic, source.available( blank(id = "autodj_startup_blank", duration = 120.), predicate.activates({!autodj_is_loading}) ) ] ) radio = fallback(id="autodj_fallback", track_sensitive = true, [dynamic_startup, radio]) ref_dynamic = ref(dynamic); thread.run.recurrent(delay=0.25, { wait_for_next_song(!ref_dynamic) }) EOF ); } if (!empty($scheduleSwitchesInterrupting)) { $scheduleSwitchesInterrupting[] = '({true}, radio)'; $event->appendLines( [ '# Interrupting Schedule Switches', sprintf( 'radio = switch(id="interrupt_switch", track_sensitive=false, [ %s ])', implode(', ', $scheduleSwitchesInterrupting) ), ] ); } $event->appendBlock( <<< EOF requests = request.queue(id="requests") requests = cue_cut(id="cue_requests", requests) radio = fallback(id="requests_fallback", track_sensitive = true, [requests, radio]) add_skip_command(radio) EOF ); } /** * Write a playlist's contents to file so Liquidsoap can process it, and optionally notify * Liquidsoap of the change. * * @param Entity\StationPlaylist $playlist * @param bool $notify * * @return string|null The full path that was written to. */ public function writePlaylistFile(Entity\StationPlaylist $playlist, bool $notify = true): ?string { $station = $playlist->getStation(); $mediaStorage = $station->getMediaStorageLocation(); if (!$mediaStorage->isLocal()) { return null; } $playlistPath = $station->getRadioPlaylistsDir(); $playlistVarName = 'playlist_' . $playlist->getShortName(); $this->logger->info( 'Writing playlist file to disk...', [ 'station' => $station->getName(), 'playlist' => $playlist->getName(), ] ); $mediaBaseDir = $mediaStorage->getPath() . '/'; $playlistFile = []; $mediaQuery = $this->em->createQuery( <<<'DQL' SELECT DISTINCT sm FROM App\Entity\StationMedia sm JOIN sm.playlists spm WHERE spm.playlist = :playlist ORDER BY spm.weight ASC DQL )->setParameter('playlist', $playlist); /** @var Entity\StationMedia $mediaFile */ foreach ($mediaQuery->toIterable() as $mediaFile) { $mediaFilePath = $mediaBaseDir . $mediaFile->getPath(); $mediaAnnotations = $this->liquidsoap->annotateMedia($mediaFile); if ($playlist->getIsJingle()) { $mediaAnnotations['is_jingle_mode'] = 'true'; unset($mediaAnnotations['media_id']); } else { $mediaAnnotations['playlist_id'] = $playlist->getId(); } $annotations_str = []; foreach ($mediaAnnotations as $annotation_key => $annotation_val) { $annotations_str[] = $annotation_key . '="' . $annotation_val . '"'; } $playlistFile[] = 'annotate:' . implode(',', $annotations_str) . ':' . $mediaFilePath; } $playlistFilePath = $playlistPath . '/' . $playlistVarName . '.m3u'; file_put_contents($playlistFilePath, implode("\n", $playlistFile)); if ($notify) { try { $this->liquidsoap->command($station, $playlistVarName . '.reload'); } catch (Exception $e) { $this->logger->error( 'Could not reload playlist with AutoDJ.', [ 'message' => $e->getMessage(), 'playlist' => $playlistVarName, 'station' => $station->getId(), ] ); } } return $playlistFilePath; } /** * Given a scheduled playlist, return the time criteria that Liquidsoap can use to determine when to play it. * * @param WriteLiquidsoapConfiguration $event * @param Entity\StationSchedule $playlistSchedule * @return string */ protected function getScheduledPlaylistPlayTime( WriteLiquidsoapConfiguration $event, Entity\StationSchedule $playlistSchedule ): string { $start_time = $playlistSchedule->getStartTime(); $end_time = $playlistSchedule->getEndTime(); // Handle multi-day playlists. if ($start_time > $end_time) { $play_times = [ self::formatTimeCode($start_time) . '-23h59m59s', '00h00m-' . self::formatTimeCode($end_time), ]; $playlist_schedule_days = $playlistSchedule->getDays(); if (!empty($playlist_schedule_days) && count($playlist_schedule_days) < 7) { $current_play_days = []; $next_play_days = []; foreach ($playlist_schedule_days as $day) { $current_play_days[] = (($day === 7) ? '0' : $day) . 'w'; $day++; if ($day > 7) { $day = 1; } $next_play_days[] = (($day === 7) ? '0' : $day) . 'w'; } $play_times[0] = '(' . implode(' or ', $current_play_days) . ') and ' . $play_times[0]; $play_times[1] = '(' . implode(' or ', $next_play_days) . ') and ' . $play_times[1]; } return '(' . implode(') or (', $play_times) . ')'; } // Handle once-per-day playlists. $play_time = ($start_time === $end_time) ? self::formatTimeCode($start_time) : self::formatTimeCode($start_time) . '-' . self::formatTimeCode($end_time); $playlist_schedule_days = $playlistSchedule->getDays(); if (!empty($playlist_schedule_days) && count($playlist_schedule_days) < 7) { $play_days = []; foreach ($playlist_schedule_days as $day) { $play_days[] = (($day === 7) ? '0' : $day) . 'w'; } $play_time = '(' . implode(' or ', $play_days) . ') and ' . $play_time; } // Handle start-date and end-date boundaries. $startDate = $playlistSchedule->getStartDate(); $endDate = $playlistSchedule->getEndDate(); if (!empty($startDate) || !empty($endDate)) { $tzObject = $event->getStation()->getTimezoneObject(); $customFunctionBody = []; $scheduleMethod = 'schedule_' . $playlistSchedule->getIdRequired() . '_date_range'; $customFunctionBody[] = 'def ' . $scheduleMethod . '() ='; $conditions = []; if (!empty($startDate)) { $startDateObj = CarbonImmutable::createFromFormat('Y-m-d', $startDate, $tzObject); if (false !== $startDateObj) { $startDateObj = $startDateObj->setTime(0, 0); $customFunctionBody[] = ' # ' . $startDateObj->__toString(); $customFunctionBody[] = ' range_start = ' . $startDateObj->getTimestamp() . '.'; $conditions[] = 'range_start <= current_time'; } } if (!empty($endDate)) { $endDateObj = CarbonImmutable::createFromFormat('Y-m-d', $endDate, $tzObject); if (false !== $endDateObj) { $endDateObj = $endDateObj->setTime(23, 59, 59); $customFunctionBody[] = ' # ' . $endDateObj->__toString(); $customFunctionBody[] = ' range_end = ' . $endDateObj->getTimestamp() . '.'; $conditions[] = 'current_time <= range_end'; } } $customFunctionBody[] = ' current_time = time()'; $customFunctionBody[] = ' ' . implode(' and ', $conditions); $customFunctionBody[] = 'end'; $event->appendLines($customFunctionBody); $play_time = $scheduleMethod . '() and ' . $play_time; } return $play_time; } public function writeCrossfadeConfiguration(WriteLiquidsoapConfiguration $event): void { $settings = $event->getStation()->getBackendConfig(); // Write pre-crossfade section. $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_FADE); // Crossfading happens before the live broadcast is mixed in, because of buffer issues. $crossfade_type = $settings->getCrossfadeType(); $crossfade = $settings->getCrossfade(); $crossDuration = $settings->getCrossfadeDuration(); if ($settings->isCrossfadeEnabled()) { $crossfadeIsSmart = (Entity\StationBackendConfiguration::CROSSFADE_SMART === $crossfade_type) ? 'true' : 'false'; $event->appendLines([ sprintf( 'radio = crossfade(smart=%1$s, duration=%2$s, fade_out=%3$s, fade_in=%3$s, radio)', $crossfadeIsSmart, self::toFloat($crossDuration), self::toFloat($crossfade) ), ]); } } public function writeHarborConfiguration(WriteLiquidsoapConfiguration $event): void { $station = $event->getStation(); if (!$station->getEnableStreamers()) { return; } $this->writeCustomConfigurationSection($event, self::CUSTOM_PRE_LIVE); $settings = $station->getBackendConfig(); $charset = $settings->getCharset(); $dj_mount = $settings->getDjMountPoint(); $recordLiveStreams = $settings->recordStreams(); $event->appendBlock( <<< EOF # DJ Authentication last_authenticated_dj = ref("") live_dj = ref("") live_record_path = ref("") def dj_auth(login) = auth_info = if (login.user == "source" or login.user == "") and (string.match(pattern="(:|,)+", login.password)) then auth_string = string.split(separator="(:|,)", login.password) {user = list.nth(default="", auth_string, 0), password = list.nth(default="", auth_string, 2)} else {user = login.user, password = login.password} end response = azuracast_api_call( timeout=5, "auth", json.stringify(auth_info) ) if response.success then last_authenticated_dj := auth_info.user true else false end end def live_connected(header) = dj = !last_authenticated_dj log("DJ Source connected! Last authenticated DJ: #{dj} - #{header}") live_enabled := true live_dj := dj j = json() j.add("user", dj) response = azuracast_api_call( timeout=5, "djon", json.stringify(j) ) if response.success and string.contains(prefix="/", response.data) then live_record_path := response.data end end def live_disconnected() = dj = !live_dj j = json() j.add("user", dj) _ = azuracast_api_call( timeout=5, "djoff", json.stringify(j) ) live_enabled := false last_authenticated_dj := "" live_dj := "" live_record_path := "" end EOF ); $harbor_params = [ '"' . self::cleanUpString($dj_mount) . '"', 'id = "input_streamer"', 'port = ' . $this->liquidsoap->getStreamPort($station), 'auth = dj_auth', 'icy = true', 'icy_metadata_charset = "' . $charset . '"', 'metadata_charset = "' . $charset . '"', 'on_connect = live_connected', 'on_disconnect = live_disconnected', ]; $djBuffer = (int)($settings['dj_buffer'] ?? 5); if (0 !== $djBuffer) { $harbor_params[] = 'buffer = ' . self::toFloat($djBuffer); $harbor_params[] = 'max = ' . self::toFloat(max($djBuffer + 5, 10)); } $harborParams = implode(', ', $harbor_params); $event->appendBlock( <<getRecordStreamsFormatEnum() ?? StreamFormats::Mp3; $recordLiveStreamsBitrate = (int)($settings['record_streams_bitrate'] ?? 128); $formatString = $this->getOutputFormatString($recordLiveStreamsFormat, $recordLiveStreamsBitrate); $event->appendBlock( <<< EOF # Record Live Broadcasts output.file( {$formatString}, fun () -> begin path = !live_record_path if (path != "") then "#{path}.tmp" else "" end end, live, fallible=true, on_close=fun (tempPath) -> begin path = string.replace(pattern=".tmp$", (fun(_) -> ""), tempPath) log("Recording stopped: Switching from #{tempPath} to #{path}") process.run("mv #{tempPath} #{path}") () end ) EOF ); } } public function writePreBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void { $station = $event->getStation(); $settings = $station->getBackendConfig(); $event->appendBlock( <<useNormalizer()) { $event->appendBlock( <<useReplayGain()) { $event->appendBlock( <<environment->isDocker() ? '/usr/local/share/icecast/web/error.mp3' : $this->environment->getBaseDirectory() . '/resources/error.mp3'; // Check for a custom station fallback file. $stationFallback = $station->getFallbackPath(); if (!empty($stationFallback)) { $fsConfig = (new StationFilesystems($station))->getConfigFilesystem(); if ($fsConfig->fileExists($stationFallback)) { $errorFile = $fsConfig->getLocalPath($stationFallback); } } $event->appendBlock( <<writeCustomConfigurationSection($event, self::CUSTOM_PRE_BROADCAST); $event->appendBlock( <<getStation(); if (FrontendAdapters::Remote === $station->getFrontendTypeEnum()) { return; } $ls_config = [ '# Local Broadcasts', ]; // Configure the outbound broadcast. $i = 0; foreach ($station->getMounts() as $mount_row) { $i++; /** @var Entity\StationMount $mount_row */ if (!$mount_row->getEnableAutodj()) { continue; } $ls_config[] = $this->getOutputString($station, $mount_row, 'local_', $i); } $event->appendLines($ls_config); } /** * Given outbound broadcast information, produce a suitable LiquidSoap configuration line for the stream. */ protected function getOutputString( Entity\Station $station, Entity\Interfaces\StationMountInterface $mount, string $idPrefix, int $id ): string { $charset = $station->getBackendConfig()->getCharset(); $format = $mount->getAutodjFormatEnum() ?? StreamFormats::default(); $output_format = $this->getOutputFormatString( $format, $mount->getAutodjBitrate() ?? 128 ); $output_params = []; $output_params[] = $output_format; $output_params[] = 'id="' . $idPrefix . $id . '"'; $output_params[] = 'host = "' . self::cleanUpString($mount->getAutodjHost()) . '"'; $output_params[] = 'port = ' . (int)$mount->getAutodjPort(); $username = $mount->getAutodjUsername(); if (!empty($username)) { $output_params[] = 'user = "' . self::cleanUpString($username) . '"'; } $password = self::cleanUpString($mount->getAutodjPassword()); $adapterType = $mount->getAutodjAdapterTypeEnum(); if (FrontendAdapters::Shoutcast === $adapterType) { $password .= ':#' . $id; } $output_params[] = 'password = "' . $password . '"'; $protocol = $mount->getAutodjProtocolEnum(); if (!empty($mount->getAutodjMount())) { if (StreamProtocols::Icy === $protocol) { $output_params[] = 'icy_id = ' . $id; } else { $output_params[] = 'mount = "' . self::cleanUpString($mount->getAutodjMount()) . '"'; } } $output_params[] = 'name = "' . self::cleanUpString($station->getName()) . '"'; $output_params[] = 'description = "' . self::cleanUpString($station->getDescription()) . '"'; $output_params[] = 'genre = "' . self::cleanUpString($station->getGenre()) . '"'; if (!empty($station->getUrl())) { $output_params[] = 'url = "' . self::cleanUpString($station->getUrl()) . '"'; } $output_params[] = 'public = ' . ($mount->getIsPublic() ? 'true' : 'false'); $output_params[] = 'encoding = "' . $charset . '"'; if (null !== $protocol) { $output_params[] = 'protocol="' . $protocol->value . '"'; } if ($format->sendIcyMetadata()) { $output_params[] = 'icy_metadata="true"'; } $output_params[] = 'radio'; return 'output.icecast(' . implode(', ', $output_params) . ')'; } protected function getOutputFormatString(StreamFormats $format, int $bitrate = 128): string { switch ($format) { case StreamFormats::Aac: $afterburner = ($bitrate >= 160) ? 'true' : 'false'; $aot = ($bitrate >= 96) ? 'mpeg4_aac_lc' : 'mpeg4_he_aac_v2'; return '%fdkaac(channels=2, samplerate=44100, bitrate=' . $bitrate . ', afterburner=' . $afterburner . ', aot="' . $aot . '", sbr_mode=true)'; case StreamFormats::Ogg: return '%vorbis.cbr(samplerate=44100, channels=2, bitrate=' . $bitrate . ')'; case StreamFormats::Opus: return '%opus(samplerate=48000, bitrate=' . $bitrate . ', vbr="constrained", application="audio", channels=2, signal="music", complexity=10, max_bandwidth="full_band")'; case StreamFormats::Flac: return '%ogg(%flac(samplerate=48000, channels=2, compression=4, bits_per_sample=24))'; case StreamFormats::Mp3: default: return '%mp3(samplerate=44100, stereo=true, bitrate=' . $bitrate . ', id3v2=true)'; } } public function writeRemoteBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void { $station = $event->getStation(); $ls_config = [ '# Remote Relays', ]; // Set up broadcast to remote relays. $i = 0; foreach ($station->getRemotes() as $remote_row) { $i++; /** @var Entity\StationRemote $remote_row */ if (!$remote_row->getEnableAutodj()) { continue; } $ls_config[] = $this->getOutputString($station, $remote_row, 'relay_', $i); } $event->appendLines($ls_config); } public function writePostBroadcastConfiguration(WriteLiquidsoapConfiguration $event): void { $this->writeCustomConfigurationSection($event, self::CUSTOM_BOTTOM); } /** * @return string[] */ public static function getCustomConfigurationSections(): array { return [ self::CUSTOM_TOP, self::CUSTOM_PRE_PLAYLISTS, self::CUSTOM_PRE_FADE, self::CUSTOM_PRE_LIVE, self::CUSTOM_PRE_BROADCAST, self::CUSTOM_BOTTOM, ]; } /** * Convert an integer or float into a Liquidsoap configuration compatible float. * * @param float|int|string $number * @param int $decimals */ public static function toFloat(float|int|string $number, int $decimals = 2): string { return number_format((float)$number, $decimals, '.', ''); } public static function formatTimeCode(int $time_code): string { $hours = floor($time_code / 100); $mins = $time_code % 100; return $hours . 'h' . $mins . 'm'; } /** * Filter a user-supplied string to be a valid LiquidSoap config entry. * * @param string|null $string * */ public static function cleanUpString(?string $string): string { return str_replace(['"', "\n", "\r"], ['\'', '', ''], $string ?? ''); } /** * Apply a more aggressive string filtering to variable names used in Liquidsoap. * * @param string $str * * @return string The cleaned up, variable-name-friendly string. */ public static function cleanUpVarName(string $str): string { $str = strip_tags($str); $str = preg_replace(['/[\r\n\t ]+/', '/[\"\*\/\:\<\>\?\'\|]+/'], ' ', $str) ?? ''; $str = strtolower($str); $str = html_entity_decode($str, ENT_QUOTES, "utf-8"); $str = htmlentities($str, ENT_QUOTES, "utf-8"); $str = preg_replace("/(&)([a-z])([a-z]+;)/i", '$2', $str) ?? ''; $str = str_replace(' ', '_', $str); $str = rawurlencode($str); $str = str_replace(['%', '-', '.'], ['', '_', '_'], $str); return $str; } }