(int) $station_id, 'format' => 'pls' === $format ? 'pls' : 'm3u', ), home_url( '/' ) ); } /** * URL of the OBS overlay page for a station. * * @param int $station_id Station. * @return string */ public static function overlay_url( $station_id ) { return add_query_arg( self::QUERY_OVERLAY, (int) $station_id, home_url( '/' ) ); } /** * Add the external-player links to the public station payload. * * @param array $data Payload. * @param int $station_id Station. * @return array */ public function station_links( $data, $station_id ) { if ( ! empty( $data['streams'] ) ) { $data['m3u'] = self::playlist_url( $station_id, 'm3u' ); $data['pls'] = self::playlist_url( $station_id, 'pls' ); } return $data; } /** * Expose the URL patterns to the admin app (Settings → Feeds & overlays). * * @param array $data Admin bootstrap. * @return array */ public function admin_data( $data ) { $data['feeds'] = array( 'm3u' => self::playlist_url( 0, 'm3u' ), 'pls' => self::playlist_url( 0, 'pls' ), 'overlay' => self::overlay_url( 0 ), 'text' => rest_url( self::NS . '/now-playing/0/text' ), 'json' => rest_url( self::NS . '/now-playing/0' ), ); return $data; } /* -------------------------------------------------------- front-end pages */ /** * Serve the playlist file or the overlay page. */ public function render() { $station_id = (int) get_query_var( self::QUERY_PLAYLIST ); if ( $station_id ) { $this->render_playlist( $station_id ); } $station_id = (int) get_query_var( self::QUERY_OVERLAY ); if ( $station_id ) { $this->render_overlay( $station_id ); } } /** * .m3u / .pls download. * * @param int $station_id Station. */ private function render_playlist( $station_id ) { $station = Station::to_public( $station_id ); if ( ! $station || empty( $station['streams'] ) ) { status_header( 404 ); exit; } $format = 'pls' === self::query_arg( 'format' ) ? 'pls' : 'm3u'; $slug = sanitize_title( $station['name'] ); $slug = $slug ? $slug : 'station'; nocache_headers(); header( 'X-Content-Type-Options: nosniff' ); header( 'Content-Disposition: attachment; filename="' . $slug . '.' . $format . '"' ); // Only the station's own stream URLs (sanitized as URLs) and its name/labels (sanitized as text) // go into the file, and every line is escaped again when it is printed. $name = sanitize_text_field( (string) $station['name'] ); $entries = array(); foreach ( $station['streams'] as $stream ) { $url = empty( $stream['url'] ) ? '' : esc_url_raw( (string) $stream['url'], array( 'http', 'https' ) ); if ( '' === $url ) { continue; } $label = empty( $stream['label'] ) ? '' : sanitize_text_field( (string) $stream['label'] ); $entries[] = array( 'url' => $url, 'title' => $name . ( '' !== $label ? ' (' . $label . ')' : '' ), ); } if ( 'pls' === $format ) { header( 'Content-Type: audio/x-scpls; charset=utf-8' ); echo "[playlist]\r\n"; foreach ( $entries as $i => $entry ) { $n = $i + 1; echo 'File' . absint( $n ) . '=' . esc_url_raw( $entry['url'] ) . "\r\n"; echo 'Title' . absint( $n ) . '=' . esc_html( $entry['title'] ) . "\r\n"; echo 'Length' . absint( $n ) . "=-1\r\n"; } echo 'NumberOfEntries=' . absint( count( $entries ) ) . "\r\n"; echo "Version=2\r\n"; } else { header( 'Content-Type: audio/x-mpegurl; charset=utf-8' ); echo "#EXTM3U\r\n"; foreach ( $entries as $entry ) { echo '#EXTINF:-1,' . esc_html( $entry['title'] ) . "\r\n"; echo esc_url_raw( $entry['url'] ) . "\r\n"; } } exit; } /** * Transparent now-playing card for OBS / vMix browser sources. * * Query options: `theme=light|dark` (default dark text on transparent), `art=0` hides artwork, * `size=sm|md|lg`, `align=left|center`. * * @param int $station_id Station. */ private function render_overlay( $station_id ) { $station = Station::to_public( $station_id ); if ( ! $station ) { status_header( 404 ); exit; } $np = Resolver::now_playing( $station_id ); $theme = 'light' === self::query_arg( 'theme' ) ? 'light' : 'dark'; $show_art = '0' !== self::query_arg( 'art' ); $size = self::query_arg( 'size' ); $size = in_array( $size, array( 'sm', 'lg' ), true ) ? $size : 'md'; $align = 'center' === self::query_arg( 'align' ) ? 'center' : 'left'; $interval = max( 5, (int) streamsera_get_setting( 'cache_ttl', 15 ) ); $endpoint = rest_url( self::NS . '/now-playing/' . $station_id ); $classes = array( 'ss-ov-theme-' . $theme, 'ss-ov-size-' . $size, 'ss-ov-align-' . $align ); nocache_headers(); header( 'Content-Type: text/html; charset=utf-8' ); ?> > <?php echo esc_html( $station['name'] ); ?> · <?php esc_html_e( 'Now playing overlay', 'streamsera' ); ?>
src="" hidden data-ss-show-art="">
\d+)/text', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'args' => array( 'station' => array( 'sanitize_callback' => 'absint', 'validate_callback' => static function ( $value ) { return is_numeric( $value ) && (int) $value > 0; }, ), 'format' => array( 'type' => 'string', 'default' => '{artist} - {title}', 'maxLength' => 200, 'sanitize_callback' => 'sanitize_text_field', 'validate_callback' => 'rest_validate_request_arg', ), ), 'callback' => array( $this, 'text' ), ) ); } /** * Plain-text now playing. `format` accepts {artist}, {title}, {raw}, {station}, {listeners}. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function text( WP_REST_Request $request ) { $station_id = (int) streamsera_url_param( $request, 'station' ); $station = Station::to_public( $station_id ); if ( ! $station ) { return new WP_REST_Response( '', 404 ); } $np = Resolver::now_playing( $station_id ); $format = sanitize_text_field( (string) $request['format'] ); $text = strtr( $format, array( '{artist}' => isset( $np['artist'] ) ? sanitize_text_field( (string) $np['artist'] ) : '', '{title}' => isset( $np['title'] ) ? sanitize_text_field( (string) $np['title'] ) : '', '{raw}' => isset( $np['raw'] ) ? sanitize_text_field( (string) $np['raw'] ) : '', '{station}' => sanitize_text_field( (string) $station['name'] ), '{listeners}' => isset( $np['listeners'] ) && null !== $np['listeners'] ? (string) $np['listeners'] : '', ) ); // "{artist} - {title}" with no artist collapses to just the title. $text = trim( preg_replace( '/^\s*-\s*|\s*-\s*$/', '', $text ) ); if ( '' === $text && ! empty( $np['raw'] ) ) { $text = sanitize_text_field( (string) $np['raw'] ); } $response = new WP_REST_Response( $text ); $response->header( 'Content-Type', 'text/plain; charset=utf-8' ); $response->header( 'Cache-Control', 'public, max-age=' . max( 3, (int) ( isset( $np['ttl'] ) ? $np['ttl'] : 10 ) ) ); add_filter( 'rest_pre_serve_request', array( $this, 'serve_text' ), 10, 2 ); return $response; } /** * Serve a text/plain response body without JSON encoding. * * @param bool $served Whether served. * @param WP_REST_Response $result Result. * @return bool */ public function serve_text( $served, $result ) { if ( $served || ! $result instanceof WP_REST_Response ) { return $served; } $type = $result->get_headers(); if ( empty( $type['Content-Type'] ) || 0 !== strpos( $type['Content-Type'], 'text/plain' ) ) { return $served; } echo esc_html( sanitize_text_field( (string) $result->get_data() ) ); return true; } }