\d+)', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'args' => array( 'station' => array( 'sanitize_callback' => 'absint' ), 'force' => array( 'type' => 'boolean', 'default' => false, ), ), 'callback' => array( $this, 'now_playing' ), ) ); // Public: resolved player config + station (what the frontend island boots from). register_rest_route( self::NS, '/players/(?P\d+)/public', array( 'methods' => 'GET', 'permission_callback' => '__return_true', 'callback' => array( $this, 'player_public' ), ) ); // Admin: detect stream info. register_rest_route( self::NS, '/detect', array( 'methods' => 'POST', // Makes an outbound request: only users who may publish stations, not contributors. 'permission_callback' => array( $this, 'can_detect' ), 'args' => array( 'url' => array( 'type' => 'string', 'required' => true, // Core runs sanitize before validate: reject non-strings here instead of letting // esc_url_raw() fail on an array. 'sanitize_callback' => static function ( $value ) { return is_string( $value ) ? esc_url_raw( $value ) : ''; }, 'validate_callback' => static function ( $value ) { return is_string( $value ) && '' !== trim( $value ); }, ), ), 'callback' => array( $this, 'detect' ), ) ); // Admin: settings. register_rest_route( self::NS, '/settings', array( array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_manage' ), 'callback' => array( $this, 'get_settings' ), ), array( 'methods' => 'POST', 'permission_callback' => array( $this, 'can_manage' ), 'callback' => array( $this, 'save_settings' ), ), ) ); // Admin tools: clear caches, system status, export. register_rest_route( self::NS, '/tools/clear-cache', array( 'methods' => 'POST', 'permission_callback' => array( $this, 'can_manage' ), 'callback' => array( $this, 'clear_cache' ), 'args' => array( 'recent' => array( 'type' => 'boolean', 'default' => false, 'sanitize_callback' => 'rest_sanitize_boolean', 'validate_callback' => 'rest_validate_request_arg', ), ), ) ); register_rest_route( self::NS, '/tools/status', array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_manage' ), 'callback' => array( $this, 'status' ), ) ); register_rest_route( self::NS, '/tools/export', array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_manage' ), 'callback' => array( $this, 'export' ), 'args' => array( 'secrets' => array( 'type' => 'boolean', 'default' => false, ), ), ) ); // Admin: Settings → Streams & now playing (per-station source health, test one station). register_rest_route( self::NS, '/streams/status', array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_manage' ), 'callback' => array( $this, 'streams_status' ), ) ); register_rest_route( self::NS, '/streams/(?P\d+)/test', array( 'methods' => 'POST', 'permission_callback' => array( $this, 'can_manage' ), 'callback' => array( $this, 'streams_test' ), 'args' => array( 'id' => array( 'type' => 'integer', 'minimum' => 1, 'sanitize_callback' => 'absint', 'validate_callback' => 'rest_validate_request_arg', ), ), ) ); // Admin: overview numbers for the dashboard. register_rest_route( self::NS, '/overview', array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_edit' ), 'args' => array( 'refresh' => array( 'type' => 'boolean', 'default' => false, ), ), 'callback' => array( $this, 'overview' ), ) ); // Admin: bootstrap data for the React app (layouts, skins, defaults). register_rest_route( self::NS, '/bootstrap', array( 'methods' => 'GET', 'permission_callback' => array( $this, 'can_edit' ), 'callback' => array( $this, 'bootstrap' ), ) ); } /** * Permission: detect stream details. It makes an outbound request, so only users who may * publish stations (not contributors). * * @return bool */ public function can_detect() { /** This filter is documented in includes/Admin/Menu.php */ return current_user_can( apply_filters( 'streamsera_required_cap', 'publish_posts', 'stations' ) ); } /** * Permission: edit posts. * * @return bool */ public function can_edit() { /** This filter is documented in includes/Admin/Menu.php */ return current_user_can( apply_filters( 'streamsera_required_cap', 'edit_posts', 'rest_read' ) ); } /** * Permission: manage options. * * @return bool */ public function can_manage() { /** This filter is documented in includes/Admin/Menu.php */ return current_user_can( apply_filters( 'streamsera_required_cap', 'manage_options', 'rest_manage' ) ); } /** * Whether the current user may force a live fetch for a station: only users who can edit that * station, and at most once every 10 seconds per station (a forced fetch is synchronous and can * take several seconds). * * @param int $station_id Station ID. * @return bool */ private function may_force_fetch( $station_id ) { if ( ! $station_id || ! current_user_can( 'edit_post', $station_id ) ) { return false; } $key = 'streamsera_force_' . $station_id; if ( get_transient( $key ) ) { return false; } set_transient( $key, 1, 10 ); return true; } /** * Now playing. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function now_playing( WP_REST_Request $request ) { $station_id = (int) streamsera_url_param( $request, 'station' ); if ( ! Station::to_public( $station_id ) ) { if ( Station::POST_TYPE !== get_post_type( $station_id ) || ! current_user_can( 'edit_post', $station_id ) ) { return new WP_Error( 'streamsera_not_found', __( 'Station not found.', 'streamsera' ), array( 'status' => 404 ) ); } // A draft, previewed by someone who may edit it (station editor, builder). Only users who // may publish stations trigger a fetch from the stream server; others get what is cached. /** This filter is documented in includes/Admin/Menu.php */ $can_fetch = current_user_can( apply_filters( 'streamsera_required_cap', 'publish_posts', 'stations' ) ); $payload = Resolver::now_playing( $station_id, false, ! $can_fetch ); if ( ! is_array( $payload ) ) { $payload = Resolver::apply_override( $station_id, Resolver::empty_payload( $station_id ) ); } $response = new WP_REST_Response( $payload ); $response->header( 'Cache-Control', 'private, no-store' ); return $response; } $force = rest_sanitize_boolean( $request->get_param( 'force' ) ) && $this->may_force_fetch( (int) streamsera_url_param( $request, 'station' ) ); $payload = Resolver::now_playing( (int) streamsera_url_param( $request, 'station' ), $force ); $response = new WP_REST_Response( $payload ); // Stale/pending answers are placeholders while the background worker fetches: re-poll soon. $max_age = empty( $payload['stale'] ) ? (int) ( $payload['ttl'] ?? Resolver::ttl() ) : 3; $response->header( 'Cache-Control', 'public, max-age=' . $max_age ); return $response; } /** * Public player payload. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function player_public( WP_REST_Request $request ) { $config = Player::get_config( (int) streamsera_url_param( $request, 'id' ) ); if ( ! $config ) { return new WP_Error( 'streamsera_not_found', __( 'Player not found.', 'streamsera' ), array( 'status' => 404 ) ); } $config = Player::with_live_station( $config ); $stations = array(); foreach ( (array) $config['stations'] as $sid ) { $s = Station::to_public( $sid ); if ( $s ) { $stations[] = $s; } } $payload = array( 'config' => $config, 'station' => Station::to_public( $config['station'] ), 'stations' => $stations, 'extra' => array(), ); /** This filter is documented in includes/Frontend/Assets.php */ $payload = apply_filters( 'streamsera_player_payload', $payload, (int) streamsera_url_param( $request, 'id' ) ); return new WP_REST_Response( $payload ); } /** * Detect. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function detect( WP_REST_Request $request ) { return new WP_REST_Response( Resolver::detect( $request['url'] ) ); } /** * Settings read. * * @return WP_REST_Response */ public function get_settings() { return new WP_REST_Response( array_merge( \StreamSera\Installation::defaults(), (array) get_option( 'streamsera_settings', array() ) ) ); } /** * Settings write. * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function save_settings( WP_REST_Request $request ) { $current = (array) get_option( 'streamsera_settings', array() ); $input = (array) $request->get_json_params(); $next = self::sanitize_settings( $input, $current ); /** * Filter settings before save (modules and add-ons add their own, sanitized keys). * * Runs after self::sanitize_settings(): `$next` only holds sanitized core keys plus the * previously stored values. A callback must sanitize whatever it copies from `$input`. * * @param array $next Settings to store. * @param array $input Raw input. */ $next = apply_filters( 'streamsera_save_settings', $next, $input ); update_option( 'streamsera_settings', $next ); return new WP_REST_Response( $next ); } /** * Sanitize the core settings keys of a raw settings array. * * Used by the settings REST endpoint, the JSON import and `wp streamsera settings set`. Returns the * stored settings with every core key replaced by its sanitized value from `$input` (or its * default); keys this method does not know are NOT copied from `$input` — modules and add-ons * persist their own keys through the `streamsera_save_settings` filter, which callers apply next. * * @param array $input Raw input. * @param array $current Currently stored settings. * @return array */ public static function sanitize_settings( array $input, array $current ): array { // Only scalars from here on (lists are handled explicitly), so casts below never see arrays. foreach ( $input as $key => $value ) { if ( ! is_scalar( $value ) && null !== $value && 'dismissed_tips' !== $key ) { unset( $input[ $key ] ); } } // Only keys present in the input change; everything else keeps its stored value (or the default). $has = static function ( $key ) use ( $input ) { return array_key_exists( $key, $input ) && null !== $input[ $key ]; }; $bool = static function ( $key, $fallback ) use ( $input, $current, $has ) { if ( $has( $key ) ) { return rest_sanitize_boolean( $input[ $key ] ); } return array_key_exists( $key, $current ) ? (bool) $current[ $key ] : $fallback; }; $int = static function ( $key, $fallback, $min, $max ) use ( $input, $current, $has ) { $value = $has( $key ) ? absint( $input[ $key ] ) : ( array_key_exists( $key, $current ) ? absint( $current[ $key ] ) : $fallback ); return min( $max, max( $min, $value ) ); }; $slug = $has( 'listen_slug' ) ? sanitize_title( (string) $input['listen_slug'] ) : ( isset( $current['listen_slug'] ) ? (string) $current['listen_slug'] : 'listen' ); $theme = $has( 'station_pages_theme' ) ? $input['station_pages_theme'] : ( isset( $current['station_pages_theme'] ) ? $current['station_pages_theme'] : 'dark' ); return array_merge( $current, array( 'listen_enabled' => $bool( 'listen_enabled', false ), 'listen_slug' => $slug ? $slug : 'listen', 'listen_player' => $int( 'listen_player', 0, 0, PHP_INT_MAX ), 'station_pages_theme' => in_array( $theme, array( 'light', 'dark' ), true ) ? $theme : 'dark', 'cache_ttl' => $int( 'cache_ttl', 15, 5, DAY_IN_SECONDS ), 'background_poll' => $bool( 'background_poll', true ), 'default_volume' => $int( 'default_volume', 80, 0, 100 ), 'keyboard_shortcuts' => $bool( 'keyboard_shortcuts', true ), 'autosave' => $bool( 'autosave', true ), 'delete_data' => $bool( 'delete_data', false ), 'setup_completed' => $bool( 'setup_completed', false ), 'dismissed_tips' => isset( $input['dismissed_tips'] ) && is_array( $input['dismissed_tips'] ) ? array_values( array_map( 'sanitize_key', $input['dismissed_tips'] ) ) : ( isset( $current['dismissed_tips'] ) ? $current['dismissed_tips'] : array() ), ) ); } /** * Clear metadata transients (and optionally an add-on's recently played history). * * @param WP_REST_Request $request Request. * @return WP_REST_Response */ public function clear_cache( WP_REST_Request $request ) { self::clear_plugin_caches(); if ( rest_sanitize_boolean( $request->get_param( 'recent' ) ) ) { /** * Clear the recently played history (add-ons that keep one). */ do_action( 'streamsera_clear_history' ); } return new WP_REST_Response( array( 'ok' => true ) ); } /** * Delete the plugin's own caches (now-playing transients and snapshots) — never the whole * object cache. * * @return void */ public static function clear_plugin_caches() { \StreamSera\Metadata\Resolver::clear_caches(); } /** * System status for the settings Tools section. * * @return WP_REST_Response */ public function status() { $upload = wp_upload_dir(); $checks = array( array( 'label' => __( 'REST API', 'streamsera' ), 'ok' => true, 'detail' => rest_url( self::NS ), ), array( 'label' => __( 'PHP version', 'streamsera' ), 'ok' => version_compare( PHP_VERSION, '7.4', '>=' ), 'detail' => PHP_VERSION, ), array( 'label' => __( 'WordPress version', 'streamsera' ), 'ok' => version_compare( get_bloginfo( 'version' ), '6.6', '>=' ), 'detail' => get_bloginfo( 'version' ), ), array( 'label' => __( 'Outbound HTTP (metadata fetch)', 'streamsera' ), 'ok' => ! defined( 'WP_HTTP_BLOCK_EXTERNAL' ) || ! WP_HTTP_BLOCK_EXTERNAL, 'detail' => defined( 'WP_HTTP_BLOCK_EXTERNAL' ) && WP_HTTP_BLOCK_EXTERNAL ? __( 'Blocked by WP_HTTP_BLOCK_EXTERNAL', 'streamsera' ) : __( 'Allowed', 'streamsera' ), ), array( 'label' => __( 'Object cache', 'streamsera' ), 'ok' => true, 'detail' => wp_using_ext_object_cache() ? __( 'Persistent object cache active', 'streamsera' ) : __( 'Transients stored in the database', 'streamsera' ), ), array( 'label' => __( 'Built assets', 'streamsera' ), 'ok' => file_exists( STREAMSERA_PATH . 'build/frontend.asset.php' ), 'detail' => file_exists( STREAMSERA_PATH . 'build/frontend.asset.php' ) ? __( 'Present', 'streamsera' ) : __( 'Missing — reinstall the plugin', 'streamsera' ), ), array( 'label' => __( 'Site uses HTTPS', 'streamsera' ), 'ok' => is_ssl() || 0 === strpos( home_url(), 'https://' ), 'detail' => 0 === strpos( home_url(), 'https://' ) ? __( 'Yes — http:// streams will be blocked by browsers (mixed content)', 'streamsera' ) : __( 'No', 'streamsera' ), ), array( 'label' => __( 'Uploads writable', 'streamsera' ), 'ok' => wp_is_writable( $upload['basedir'] ), 'detail' => $upload['basedir'], ), ); return new WP_REST_Response( array( 'checks' => apply_filters( 'streamsera_system_status', $checks ), 'version' => STREAMSERA_VERSION, ) ); } /** * One station's row for the Streams settings: where track info comes from and how the last read went. * * @param int $id Station. * @return array */ public static function stream_row( $id ) { $labels = array(); foreach ( \StreamSera\Modules\Metadata::definitions() as $def ) { $labels[ $def['id'] ] = $def['label']; } $public = Station::to_public( (int) $id ); $meta = (array) get_post_meta( (int) $id, '_streamsera_metadata', true ); $chosen = isset( $meta['provider'] ) ? (string) $meta['provider'] : 'auto'; $stat = get_option( 'streamsera_np_stat_' . (int) $id ); $stat = is_array( $stat ) ? $stat : array(); $last = get_option( 'streamsera_last_np_' . (int) $id ); $used = 'auto' === $chosen ? (string) ( $stat['provider'] ?? '' ) : $chosen; $ttl = Resolver::ttl(); $age = is_array( $last ) && ! empty( $last['updated'] ) ? max( 0, time() - (int) $last['updated'] ) : null; $ms = isset( $stat['ms'] ) ? (int) $stat['ms'] : null; if ( 'none' === $chosen ) { $state = 'off'; } elseif ( null === $age || ( isset( $stat['ok'] ) && ! $stat['ok'] ) ) { $state = 'none'; } elseif ( $age > max( 300, $ttl * 6 ) ) { $state = 'stale'; } elseif ( null !== $ms && $ms > 1500 ) { $state = 'slow'; } else { $state = 'fresh'; } $streams = (array) get_post_meta( (int) $id, '_streamsera_streams', true ); $first = $streams ? (array) reset( $streams ) : array(); $row = array( 'id' => (int) $id, 'name' => $public ? (string) $public['name'] : get_the_title( (int) $id ), 'logo' => $public ? (string) ( $public['logo'] ?? '' ) : '', 'provider' => $used && 'none' !== $used ? ( $labels[ $used ] ?? ucfirst( $used ) ) : '', 'auto' => 'auto' === $chosen, 'method' => 'polling', 'ms' => $ms, 'age' => $age, 'state' => $state, 'http' => 0 === stripos( (string) ( $first['url'] ?? '' ), 'http://' ), 'edit' => admin_url( 'admin.php?page=streamsera#/stations/' . (int) $id ), ); /** * Filter one station's row in Settings → Streams & now playing (add-ons: push sources). * * @param array $row Row. * @param int $id Station. */ return (array) apply_filters( 'streamsera_stream_row', $row, (int) $id ); } /** * Settings → Streams & now playing: every published station's source and the headline numbers. * * @return WP_REST_Response */ public function streams_status() { $ids = get_posts( array( 'post_type' => Station::POST_TYPE, 'post_status' => 'publish', 'posts_per_page' => 100, 'orderby' => 'title', 'order' => 'ASC', 'fields' => 'ids', ) ); $rows = array_map( array( __CLASS__, 'stream_row' ), $ids ); $ages = array_filter( array_map( static function ( $r ) { return $r['age']; }, $rows ), 'is_int' ); $data = array( 'stations' => $rows, 'updated' => $ages ? min( $ages ) : null, 'up' => count( array_filter( $rows, static function ( $r ) { return in_array( $r['state'], array( 'fresh', 'slow' ), true ); } ) ), 'upLabel' => 'metadata', 'total' => count( $rows ), 'https' => 0 === strpos( home_url(), 'https://' ), ); /** * Filter the Streams settings summary (add-ons: health checks, artwork and history numbers). * * @param array $data Summary. */ return new WP_REST_Response( apply_filters( 'streamsera_streams_status', $data ) ); } /** * Read one station's track info now and return its fresh row. * * @param WP_REST_Request $request Request. * @return WP_REST_Response|WP_Error */ public function streams_test( WP_REST_Request $request ) { $id = absint( $request['id'] ); if ( ! $id || Station::POST_TYPE !== get_post_type( $id ) ) { return new WP_Error( 'streamsera_bad_station', __( 'Unknown station.', 'streamsera' ), array( 'status' => 404 ) ); } $payload = Resolver::refresh( $id, true ); $row = self::stream_row( $id ); $row['track'] = trim( (string) ( $payload['raw'] ?? '' ) ); return new WP_REST_Response( $row ); } /** * Export stations + players as JSON. * * Secrets (station push keys, provider API keys in `_streamsera_metadata['fields']`, credential-like * settings — see self::redact_settings()) are left out unless the request passes `secrets=1` * and the user can `manage_options`. * * @param WP_REST_Request|null $request Request. * @return WP_REST_Response */ public function export( $request = null ) { $include_secrets = $request instanceof WP_REST_Request && rest_sanitize_boolean( $request->get_param( 'secrets' ) ) && current_user_can( 'manage_options' ); return new WP_REST_Response( self::export_data( $include_secrets ) ); } /** * Station meta keys holding credentials. * * @return string[] */ public static function secret_meta_keys() { /** * Station meta keys that hold credentials and are left out of exports (add-ons add theirs). * * @param string[] $keys Meta keys. */ return (array) apply_filters( 'streamsera_export_secret_meta_keys', array() ); } /** * Remove credentials from a post's exported `_streamsera_*` meta. * * @param array $meta Meta key => value. * @return array */ public static function redact_meta( array $meta ) { foreach ( self::secret_meta_keys() as $key ) { unset( $meta[ $key ] ); } if ( isset( $meta['_streamsera_metadata'] ) && is_array( $meta['_streamsera_metadata'] ) ) { // Provider credentials (API keys, passwords) live in `fields`. if ( ! empty( $meta['_streamsera_metadata']['fields'] ) ) { $meta['_streamsera_metadata']['fields'] = array(); } // A status URL can carry a user name and password (http://admin:pass@host:8000/…): keep the address, drop the credentials. if ( ! empty( $meta['_streamsera_metadata']['url'] ) && is_string( $meta['_streamsera_metadata']['url'] ) ) { $meta['_streamsera_metadata']['url'] = self::strip_url_credentials( $meta['_streamsera_metadata']['url'] ); } } return $meta; } /** * A URL without its `user:password@` part. * * @param string $url URL. * @return string */ public static function strip_url_credentials( $url ) { return (string) preg_replace( '#^([a-z][a-z0-9+.\-]*://)[^/@?\#]*@#i', '$1', $url ); } /** * Remove credentials from exported settings. * * Generic, name-based: top-level keys that look like a credential (`*_key`, `*_secret`, * `*_token`, `*_password`, …) are dropped, and nested `secret` / `token` / `password` / * `api_key` fields are blanked. Add-ons redact anything else via `streamsera_export_redact_settings`. * * @param array $settings Settings. * @return array */ public static function redact_settings( array $settings ) { foreach ( array_keys( $settings ) as $key ) { if ( is_string( $key ) && preg_match( '/(^|_)(key|secret|token|password|licen[cs]e)(_|$)/i', $key ) ) { unset( $settings[ $key ] ); } elseif ( is_array( $settings[ $key ] ) ) { $settings[ $key ] = self::redact_nested( $settings[ $key ] ); } } /** * Filter the settings written to an export that leaves secrets out. * * Remove or blank any setting your add-on stores credentials in. * * @param array $settings Settings after the generic redaction. */ return (array) apply_filters( 'streamsera_export_redact_settings', $settings ); } /** * Blank credential-like fields inside a nested settings value. * * @param array $value Value. * @return array */ private static function redact_nested( array $value ) { foreach ( $value as $k => $v ) { if ( is_array( $v ) ) { $value[ $k ] = self::redact_nested( $v ); } elseif ( is_string( $k ) && preg_match( '/^(secret|token|password|api_key|apikey)$/i', $k ) ) { $value[ $k ] = ''; } } return $value; } /** * Build the export payload. * * @param bool $include_secrets Keep credentials in the export. * @return array */ public static function export_data( $include_secrets = false ) { $settings = (array) get_option( 'streamsera_settings', array() ); $out = array( 'version' => STREAMSERA_VERSION, 'exported' => gmdate( 'c' ), 'settings' => $include_secrets ? $settings : self::redact_settings( $settings ), 'stations' => array(), 'players' => array(), ); foreach ( get_posts( array( 'post_type' => Station::POST_TYPE, 'numberposts' => -1, 'post_status' => 'any', ) ) as $post ) { $meta = array(); foreach ( array_keys( (array) get_post_meta( $post->ID ) ) as $key ) { if ( 0 === strpos( $key, '_streamsera_' ) ) { $meta[ $key ] = get_post_meta( $post->ID, $key, true ); } } $out['stations'][] = array( 'id' => $post->ID, 'title' => streamsera_plain_title( $post ), 'content' => $post->post_content, 'status' => $post->post_status, 'meta' => $include_secrets ? $meta : self::redact_meta( $meta ), ); } foreach ( get_posts( array( 'post_type' => Player::POST_TYPE, 'numberposts' => -1, 'post_status' => 'any', ) ) as $post ) { $out['players'][] = array( 'id' => $post->ID, 'title' => streamsera_plain_title( $post ), 'status' => $post->post_status, 'config' => get_post_meta( $post->ID, '_streamsera_config', true ), ); } if ( ! $include_secrets ) { $out['secrets_removed'] = true; } return $out; } /** * Overview numbers for the dashboard. * * @return WP_REST_Response */ public function overview( WP_REST_Request $request ) { // Authors and contributors see numbers for their own stations and players only, matching the // lists they get; `othersExist` tells the dashboard the site has published stations of other people. $own = current_user_can( 'edit_others_posts' ) ? array() : array( 'author' => get_current_user_id() ); $query = array( 'numberposts' => -1, 'post_status' => array( 'publish', 'draft' ), 'fields' => 'ids', ) + $own; $stations = get_posts( array( 'post_type' => Station::POST_TYPE ) + $query ); $players = get_posts( array( 'post_type' => Player::POST_TYPE ) + $query ); $others = $own && (bool) get_posts( array( 'post_type' => Station::POST_TYPE, 'post_status' => 'publish', 'numberposts' => 1, 'fields' => 'ids', 'author__not_in' => array( get_current_user_id() ), ) ); $status = array(); $live = 0; $listeners = 0; $pending = 0; $refresh = $this->can_edit() && rest_sanitize_boolean( $request->get_param( 'refresh' ) ); foreach ( $stations as $id ) { // Cached-only: never blocks. Stale stations are handed to the serialized background // worker when ?refresh=1 is passed (the dashboard does this once on load). $np = Resolver::now_playing( $id, false, true ); if ( ! $np || ! empty( $np['stale'] ) ) { if ( $refresh && 'publish' === get_post_status( $id ) ) { Resolver::schedule_refresh( $id ); ++$pending; } } if ( $np ) { $status[ $id ] = $np; if ( ! empty( $np['live'] ) ) { ++$live; } if ( isset( $np['listeners'] ) ) { $listeners += (int) $np['listeners']; } } } $settings = (array) get_option( 'streamsera_settings', array() ); return new WP_REST_Response( /** * Filter the dashboard overview (add-ons add their own status, e.g. a site-wide player). * * @param array $overview Overview. */ apply_filters( 'streamsera_overview', array( 'counts' => array( 'stations' => count( $stations ), 'players' => count( $players ), 'live' => $live, 'listeners' => $listeners, ), 'status' => $status, 'pending' => $pending, 'othersExist' => $others, 'placed' => self::player_placed( $settings ), 'listenUrl' => home_url( '/' ), ) ) ); } /** * Whether a player is on the site: the listen page is on, or published content embeds a StreamSera * block or shortcode. Cached for ten minutes (it is only a checklist hint). * * @param array $settings Settings. * @return bool */ private static function player_placed( array $settings ) { if ( ! empty( $settings['listen_enabled'] ) ) { return true; } $cached = get_transient( 'streamsera_player_placed' ); if ( false !== $cached ) { return (bool) $cached; } global $wpdb; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- cached in a transient above. $found = (bool) $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND ( post_content LIKE %s OR post_content LIKE %s ) LIMIT 1", '%' . $wpdb->esc_like( '