isset( $meta['provider'] ) ? $meta['provider'] : 'auto', 'url' => isset( $meta['url'] ) ? $meta['url'] : '', 'mount' => isset( $meta['mount'] ) ? $meta['mount'] : '', 'fields' => isset( $meta['fields'] ) && is_array( $meta['fields'] ) ? $meta['fields'] : array(), 'station_id' => $station_id, 'stream_url' => self::origin_stream_url( $station_id, $station ), ); $result = null; $started = microtime( true ); if ( 'none' !== $source['provider'] ) { $result = self::fetch_from_providers( $station_id, $source, $force ); } // How the last fetch went (Settings → Streams & now playing lists it per station). update_option( 'streamsera_np_stat_' . $station_id, array( 'at' => time(), 'ms' => (int) round( ( microtime( true ) - $started ) * 1000 ), 'ok' => is_array( $result ), 'provider' => 'auto' === $source['provider'] ? (string) get_post_meta( $station_id, '_streamsera_metadata_detected', true ) : (string) $source['provider'], ), false ); $payload = $result ? array_merge( $empty, $result ) : $empty; // A server that reports a current title is on air, whatever its status flag says. if ( ! empty( $payload['raw'] ) ) { $payload['live'] = true; } /** * Filter now-playing before caching (add-ons: artwork lookup, cleanup rules, ...). * * @param array $payload Payload. * @param int $station_id Station. */ $payload = self::sanitize_payload( (array) apply_filters( 'streamsera_now_playing', $payload, $station_id ) ); if ( ! empty( $payload['raw'] ) ) { self::push_recent( $station_id, $payload ); $payload['recent'] = self::recent( $station_id ); } $payload['updated'] = time(); $payload['cached'] = false; $payload['stale'] = false; $payload['pending'] = false; set_transient( $key, $payload, $ttl ); update_option( 'streamsera_last_np_' . $station_id, $payload, false ); delete_transient( $lock ); return $payload; } /** * Sanitize a now-playing payload before it is cached: titles and names come from the remote * stream server (or add-ons) and are stored, returned by the REST API and shown in players. * Keys this method does not know are left to the add-on that added them. * * @param array $payload Payload. * @return array */ public static function sanitize_payload( array $payload ) { foreach ( array( 'title', 'artist', 'raw', 'server', 'dj', 'badge' ) as $key ) { if ( array_key_exists( $key, $payload ) ) { $payload[ $key ] = is_scalar( $payload[ $key ] ) ? sanitize_text_field( (string) $payload[ $key ] ) : ''; } } if ( array_key_exists( 'artwork', $payload ) ) { $payload['artwork'] = is_string( $payload['artwork'] ) ? esc_url_raw( $payload['artwork'], array( 'http', 'https' ) ) : ''; } if ( array_key_exists( 'listeners', $payload ) ) { $payload['listeners'] = is_numeric( $payload['listeners'] ) ? max( 0, (int) $payload['listeners'] ) : null; } if ( array_key_exists( 'live', $payload ) ) { $payload['live'] = (bool) $payload['live']; } if ( array_key_exists( 'next', $payload ) ) { $next = is_array( $payload['next'] ) ? $payload['next'] : null; $payload['next'] = $next ? array( 'title' => isset( $next['title'] ) && is_scalar( $next['title'] ) ? sanitize_text_field( (string) $next['title'] ) : '', 'artist' => isset( $next['artist'] ) && is_scalar( $next['artist'] ) ? sanitize_text_field( (string) $next['artist'] ) : '', ) + $next : null; } if ( array_key_exists( 'links', $payload ) ) { $links = array(); foreach ( is_array( $payload['links'] ) ? $payload['links'] : array() as $link ) { if ( ! is_array( $link ) || empty( $link['url'] ) || ! is_string( $link['url'] ) ) { continue; } $url = esc_url_raw( $link['url'], array( 'http', 'https' ) ); if ( '' === $url ) { continue; } $links[] = array( 'id' => isset( $link['id'] ) && is_scalar( $link['id'] ) ? sanitize_key( (string) $link['id'] ) : '', 'label' => isset( $link['label'] ) && is_scalar( $link['label'] ) ? sanitize_text_field( (string) $link['label'] ) : '', 'url' => $url, ) + $link; } $payload['links'] = $links; } return $payload; } /** * Run the provider sweep for one station, bounded in time, with auto-detect back-off. * * @param int $station_id Station. * @param array $source Source definition. * @param bool $force Explicit action: ignore the back-off. * @return array|null Provider result or null. */ private static function fetch_from_providers( $station_id, array $source, $force ) { $providers = self::providers(); $auto = 'auto' === $source['provider'] || ! isset( $providers[ $source['provider'] ] ); if ( $auto ) { $detected = (string) get_post_meta( $station_id, '_streamsera_metadata_detected', true ); // Every provider failed recently: skip the sweep until the back-off expires. if ( ! $force && 'none' === $detected ) { $retry_at = (int) get_post_meta( $station_id, '_streamsera_metadata_retry_at', true ); if ( $retry_at > time() ) { return null; } } $order = array_keys( $providers ); if ( $detected && 'none' !== $detected && isset( $providers[ $detected ] ) ) { // Try the provider that worked last time first. $order = array_merge( array( $detected ), array_diff( $order, array( $detected ) ) ); } } else { $order = array( $source['provider'] ); } /** * Filter the maximum wall-clock seconds spent probing providers for one station. * * @param float $budget Seconds. * @param int $station_id Station. */ $budget = (float) apply_filters( 'streamsera_provider_budget', $force ? 20 : 10, $station_id ); $deadline = microtime( true ) + $budget; $result = null; $winner = ''; foreach ( $order as $id ) { if ( microtime( true ) >= $deadline ) { break; } $result = $providers[ $id ]->fetch( $source ); if ( is_array( $result ) ) { $winner = $id; break; } $result = null; } if ( $auto ) { if ( $winner ) { if ( get_post_meta( $station_id, '_streamsera_metadata_detected', true ) !== $winner ) { update_post_meta( $station_id, '_streamsera_metadata_detected', $winner ); } delete_post_meta( $station_id, '_streamsera_metadata_retry_at' ); } else { /** * Filter the back-off (seconds) before re-probing a station no provider answered for. * * @param int $seconds Seconds. * @param int $station_id Station. */ $backoff = (int) apply_filters( 'streamsera_detect_backoff', self::DETECT_BACKOFF, $station_id ); update_post_meta( $station_id, '_streamsera_metadata_detected', 'none' ); update_post_meta( $station_id, '_streamsera_metadata_retry_at', time() + max( 60, $backoff ) ); } } return $result; } /** * The stream URL as saved on the station (never a rewritten/proxied one), used to locate * the status endpoint on the stream host. * * @param int $station_id Station. * @param array $station Public station payload. * @return string */ private static function origin_stream_url( $station_id, array $station ) { $streams = get_post_meta( $station_id, '_streamsera_streams', true ); if ( is_array( $streams ) ) { $streams = array_values( $streams ); if ( ! empty( $streams[0]['url'] ) ) { return (string) $streams[0]['url']; } } return isset( $station['streams'][0]['url'] ) ? (string) $station['streams'][0]['url'] : ''; } /** * Empty payload skeleton. * * @param int $station_id Station. * @param array|null $station Public station (looked up when null). * @return array */ public static function empty_payload( $station_id, $station = null ) { if ( null === $station ) { $station = Station::to_public( $station_id ); } return array( 'station' => $station_id, 'title' => '', 'artist' => '', 'raw' => '', 'artwork' => '', 'listeners' => null, // A station without a stream URL can't be on air. 'live' => (bool) $station && ! empty( $station['streams'] ), 'server' => '', 'dj' => '', // Live DJ / streamer name when a provider reports one. 'badge' => '', // Overrides the live badge label (show-aware badge, DJ name). 'next' => null, // Up-next track { title, artist } when the automation exposes it. 'links' => array(), // Buy/stream links [{ id, label, url }]. 'recent' => self::recent( $station_id ), 'updated' => time(), 'ttl' => self::ttl(), ); } /** * Cache TTL (seconds). * * @return int */ public static function ttl() { return max( 5, (int) streamsera_get_setting( 'cache_ttl', 15 ) ); } /** * Whether the cached payload for a station is still fresh. * * @param int $station_id Station. * @return bool */ public static function is_fresh( $station_id ) { return is_array( get_transient( self::cache_key( absint( $station_id ) ) ) ); } /** * Transient key. * * @param int $station_id Station. * @return string */ public static function cache_key( $station_id ) { return 'streamsera_np_' . absint( $station_id ); } /** * Delete the plugin's own caches: now-playing transients and last-known now-playing snapshots per * station, plus the share-image transients stored in the options table. Leaves the rest of the object cache alone (no wp_cache_flush()). * * @return void */ public static function clear_caches() { global $wpdb; $ids = get_posts( array( 'post_type' => Station::POST_TYPE, 'post_status' => 'any', 'numberposts' => -1, 'fields' => 'ids', ) ); foreach ( $ids as $id ) { delete_transient( self::cache_key( $id ) ); delete_option( 'streamsera_last_np_' . (int) $id ); } // Keyed caches (md5 suffixes) and leftovers of deleted stations, when stored in the DB. $like = array(); foreach ( array( 'streamsera_np_', 'streamsera_share_' ) as $prefix ) { $like[] = $wpdb->esc_like( '_transient_' . $prefix ) . '%'; } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- enumerating cache rows to delete them through the API. $names = (array) $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $like[0], $like[1] ) ); foreach ( $names as $name ) { $transient = substr( (string) $name, strlen( '_transient_' ) ); if ( 0 !== strpos( $transient, 'streamsera_np_lock_' ) ) { delete_transient( $transient ); } } // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- see above. $snapshots = (array) $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", $wpdb->esc_like( 'streamsera_last_np_' ) . '%', $wpdb->esc_like( 'streamsera_np_stat_' ) . '%' ) ); foreach ( $snapshots as $name ) { delete_option( (string) $name ); } /** * Fires after the plugin's caches were cleared (Tools → Clear cache, `wp streamsera cache`). * Add-ons clear their own caches here. */ do_action( 'streamsera_clear_caches' ); } /** * Lock transient key. * * @param int $station_id Station. * @return string */ public static function lock_key( $station_id ) { return 'streamsera_np_lock_' . absint( $station_id ); } /** * Recently played list. * * @param int $station_id Station. * @return array */ public static function recent( $station_id ) { /** * Filter the recently played tracks of a station (newest first). Empty unless an add-on * keeps a track history. * * @param array $tracks Tracks { title, artist, raw, artwork, show_id, time }. * @param int $station_id Station. */ return (array) apply_filters( 'streamsera_recent_tracks', array(), absint( $station_id ) ); } /** * Push a track onto the recent list (dedupe consecutive). * * @param int $station_id Station. * @param array $payload Now-playing payload. */ private static function push_recent( $station_id, $payload ) { /** * Fires when a station reports a track (add-ons log it; consecutive repeats included). * * @param int $station_id Station. * @param array $payload Now-playing payload. */ do_action( 'streamsera_track_changed', absint( $station_id ), $payload ); } /** * Detect server type / codec info for a stream URL (used by "Auto-detect stream info"). * Explicit admin action: synchronous, but bounded. * * @param string $url Stream URL. * @return array */ public static function detect( $url ) { $url = esc_url_raw( $url ); $info = array( 'url' => $url, 'reachable' => false, 'server' => '', 'content_type' => '', 'bitrate' => null, 'name' => '', 'metadata' => null, ); if ( ! $url || ! in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'http', 'https' ), true ) ) { return $info; } // The URL comes from the admin form, not from saved data: everything below (the header probe // and the providers' status pages) stays on the safe transport, whatever the site allows for // its background poller (SSRF). $previous = self::$safe_requests; self::$safe_requests = true; try { return self::detect_safely( $url, $info ); } finally { self::$safe_requests = $previous; } } /** * detect() with safe requests switched on. * * @param string $url Stream URL. * @param array $info Result skeleton. * @return array */ private static function detect_safely( $url, array $info ) { // A live stream never ends, so read only its response headers (probe_headers() stops at the // first audio byte). $response = self::probe_headers( $url ); if ( ! is_wp_error( $response ) ) { $code = wp_remote_retrieve_response_code( $response ); $info['reachable'] = $code >= 200 && $code < 400; $info['content_type'] = wp_remote_retrieve_header( $response, 'content-type' ); $server = strtolower( (string) wp_remote_retrieve_header( $response, 'server' ) ); $icy_name = wp_remote_retrieve_header( $response, 'icy-name' ); $icy_br = wp_remote_retrieve_header( $response, 'icy-br' ); $info['name'] = is_string( $icy_name ) ? $icy_name : ''; $info['bitrate'] = $icy_br ? (int) $icy_br : null; if ( false !== strpos( $server, 'icecast' ) ) { $info['server'] = 'icecast'; } elseif ( false !== strpos( $server, 'shoutcast' ) || wp_remote_retrieve_header( $response, 'icy-notice1' ) ) { $info['server'] = 'shoutcast'; } } // Probe providers for metadata (detected server type first); whichever answers wins. $providers = self::providers(); if ( $info['server'] && isset( $providers[ $info['server'] ] ) ) { $providers = array( $info['server'] => $providers[ $info['server'] ] ) + $providers; } /** This filter is documented above in fetch_from_providers(). */ $deadline = microtime( true ) + (float) apply_filters( 'streamsera_provider_budget', 20, 0 ); // Providers derive status URLs from the submitted stream URL: detect() keeps them on the safe transport. foreach ( $providers as $id => $provider ) { if ( microtime( true ) >= $deadline ) { break; } $result = $provider->fetch( array( 'provider' => $id, 'url' => '', 'mount' => '', 'fields' => array(), 'station_id' => 0, 'stream_url' => $url, ) ); if ( is_array( $result ) ) { $info['metadata'] = $result; if ( ! $info['server'] ) { $info['server'] = $id; } if ( ! $info['bitrate'] && ! empty( $result['bitrate'] ) ) { $info['bitrate'] = (int) $result['bitrate']; } if ( ! $info['name'] && ! empty( $result['server_name'] ) ) { $info['name'] = $result['server_name']; } $info['reachable'] = true; break; } } return $info; } /** * Read a stream's response headers without downloading the stream. * * A live stream never ends, so the WordPress HTTP API can only time out on it. This reads the * status line and headers through Stream_Reader (a validated, IP-pinned socket that follows up * to 5 redirects) and disconnects at the first audio byte. * * With `$read_meta`, reading continues just past the first in-stream ICY metadata block * (`icy-metaint` audio bytes + 1 length byte + up to 4080 bytes) and the decoded block is * returned as `icy_meta` — that is how Zeno, SurferNetwork and many hosted streams expose the * current title without any status page. * * @param string $url Stream URL. * @param int $timeout Seconds for the whole exchange. * @param bool $read_meta Also read the first in-stream metadata block. * @return array|\WP_Error Response array shaped like wp_remote_get()'s (headers, response), plus * `icy_meta`, or a WP_Error. */ public static function probe_headers( $url, $timeout = 6, $read_meta = false ) { $until = static function ( $body, $headers ) { $every = isset( $headers['icy-metaint'] ) ? (int) $headers['icy-metaint'] : 0; if ( $every <= 0 || $every > 65536 ) { return true; // No in-stream metadata: nothing to wait for. } if ( strlen( $body ) <= $every ) { return false; } return strlen( $body ) >= $every + 1 + ord( $body[ $every ] ) * 16; }; $res = Stream_Reader::read_start( $url, array( 'headers' => array( 'Icy-MetaData' => '1', 'Accept' => '*/*', 'User-Agent' => 'StreamSera/' . STREAMSERA_VERSION, ), 'timeout' => max( 1, (int) $timeout ), 'max_body' => $read_meta ? 65536 + 1 + 4080 : 0, 'until' => $until, 'redirection' => 5, ) ); if ( is_wp_error( $res ) ) { return $res; } $meta = ''; $body = (string) $res['body']; $every = isset( $res['headers']['icy-metaint'] ) ? (int) $res['headers']['icy-metaint'] : 0; if ( $read_meta && $every > 0 && strlen( $body ) > $every ) { $meta = rtrim( substr( $body, $every + 1, ord( $body[ $every ] ) * 16 ), "\0" ); } return array( 'headers' => $res['headers'], // lowercase keys: wp_remote_retrieve_header() reads them directly 'icy_meta' => $meta, 'body' => '', 'response' => array( 'code' => (int) $res['code'], 'message' => '', ), 'cookies' => array(), 'filename' => null, ); } /* ---------- helpers shared by providers ---------- */ /** * Build a status URL on the stream host. * * @param string $stream_url Stream URL. * @param string $path Path to append (e.g. /status-json.xsl). * @return string */ public static function status_url( $stream_url, $path ) { $parts = wp_parse_url( $stream_url ); if ( empty( $parts['host'] ) ) { return ''; } // Never probe ourselves (a proxied/relayed stream URL points back at this site). $home = wp_parse_url( home_url() ); if ( ! empty( $home['host'] ) && 0 === strcasecmp( $home['host'], $parts['host'] ) ) { return ''; } $scheme = isset( $parts['scheme'] ) ? $parts['scheme'] : 'http'; $port = isset( $parts['port'] ) ? ':' . $parts['port'] : ''; return $scheme . '://' . $parts['host'] . $port . $path; } /** * Mount name from a stream URL (/radio.mp3 -> /radio.mp3). * * @param string $stream_url Stream URL. * @return string */ public static function mount_from_url( $stream_url ) { $path = wp_parse_url( $stream_url, PHP_URL_PATH ); $path = is_string( $path ) ? rtrim( $path, '/' ) : ''; return in_array( $path, array( '', '/;', '/stream' ), true ) && '' === $path ? '' : $path; } /** * HTTP GET with short timeout; returns body or ''. * * @param string $url URL. * @param float|null $timeout Seconds (default: `streamsera_http_timeout`, 3). * @return string */ public static function http_get( $url, $timeout = null ) { if ( ! $url ) { return ''; } if ( null === $timeout ) { /** * Filter the timeout for metadata status requests. * * @param float $timeout Seconds. * @param string $url URL. */ $timeout = (float) apply_filters( 'streamsera_http_timeout', 3, $url ); } $args = array( 'timeout' => $timeout, 'redirection' => 2, 'limit_response_size' => 512 * 1024, 'user-agent' => 'StreamSera/' . STREAMSERA_VERSION, 'headers' => array( 'Accept' => 'application/json, text/html;q=0.8, */*;q=0.5' ), ); $response = self::allow_private_hosts( $url ) ? wp_remote_get( $url, $args ) : self::safe_get( $url, $args ); if ( is_wp_error( $response ) || 200 !== wp_remote_retrieve_response_code( $response ) ) { return ''; } return (string) wp_remote_retrieve_body( $response ); } /** * `wp_safe_remote_get()` that also accepts the usual streaming-server ports. WordPress's safe * requests only allow ports 80, 443 and 8080, but Icecast / Shoutcast servers typically listen on * 8000–9000; the host is still checked against private / loopback addresses. * * @param string $url URL. * @param array $args Request args. * @return array|\WP_Error */ public static function safe_get( $url, $args = array() ) { // Core's safe request refuses private and loopback hosts; also refuse the shared / benchmarking // ranges it does not cover. $host = (string) wp_parse_url( $url, PHP_URL_HOST ); $ip = $host && filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) ? $host : ( $host ? gethostbyname( $host ) : '' ); if ( $ip && filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4 ) && Stream_Reader::in_special_range( $ip ) ) { return new \WP_Error( 'streamsera_url_refused', __( 'This address is not allowed.', 'streamsera' ) ); } $port = (int) wp_parse_url( $url, PHP_URL_PORT ); /** * Whether a non-standard port may be used for stream / status requests. * * @param bool $allowed Default: ports 8000–9000. * @param int $port Port. */ $extra = $port && ! in_array( $port, array( 80, 443, 8080 ), true ) && (bool) apply_filters( 'streamsera_stream_port_allowed', $port >= 8000 && $port <= 9000, $port ); $cb = null; if ( $extra ) { $cb = static function ( $ports ) use ( $port ) { $ports = (array) $ports; $ports[] = $port; return $ports; }; add_filter( 'http_allowed_safe_ports', $cb ); } try { return wp_safe_remote_get( $url, $args ); } finally { if ( $cb ) { remove_filter( 'http_allowed_safe_ports', $cb ); } } } /** * Whether a stream / status URL may be on a private host (see allow_private_hosts()). * Never while safe requests are on (URLs submitted in admin forms). * * @param string $url URL. * @return bool */ public static function private_hosts_allowed( $url ) { return self::allow_private_hosts( $url ); } /** * Whether http_get() may reach loopback / private / link-local hosts for this URL. * * Never while self::$safe_requests is set (URLs from a REST request). Otherwise only when a site * opts in through the `streamsera_allow_private_stream_hosts` filter. * * @param string $url URL about to be fetched. * @return bool */ private static function allow_private_hosts( $url ) { if ( self::$safe_requests ) { return false; } /** * Allow the background metadata poller to fetch saved stream / status URLs on private hosts. * * By default every metadata request goes through wp_safe_remote_get(), which refuses * loopback, private (10/8, 172.16/12, 192.168/16) and link-local addresses. Return true if * your stream server (Icecast, Shoutcast, ...) runs on the same LAN as WordPress and is * addressed by a private IP or internal host name. URLs submitted in admin forms (stream * auto-detect, provider tests) always stay on the safe transport regardless of this filter. * * Example: add_filter( 'streamsera_allow_private_stream_hosts', '__return_true' ); * * @param bool $allow Default false. * @param string $url URL about to be fetched. */ return true === apply_filters( 'streamsera_allow_private_stream_hosts', false, $url ); } /** * Split "Artist - Title" into parts. * * @param string $raw Raw title. * @param string $artist Artist if the server gives it separately. * @return array{title:string,artist:string} */ public static function split_title( $raw, $artist = '' ) { $raw = is_scalar( $raw ) ? trim( html_entity_decode( (string) $raw, ENT_QUOTES, 'UTF-8' ) ) : ''; $artist = is_scalar( $artist ) ? (string) $artist : ''; if ( $artist ) { return array( 'title' => $raw, 'artist' => trim( (string) $artist ), ); } if ( preg_match( '/^(.+?)\s+[-–—]\s+(.+)$/u', $raw, $m ) ) { return array( 'title' => trim( $m[2] ), 'artist' => trim( $m[1] ), ); } return array( 'title' => $raw, 'artist' => '', ); } }