* : list | get | import-csv * * [] * : Station ID (get) or CSV path (import-csv). * * [--format=] * : table, json, csv, yaml. Default: table. * * [--status=] * : Post status for imported stations (publish|draft). Default: publish. * * ## EXAMPLES * * wp streamsera station list * wp streamsera station get 5 --format=json * wp streamsera station import-csv stations.csv * * @param array $args Positional args. * @param array $assoc_args Flags. */ public function station( $args, $assoc_args ) { $action = isset( $args[0] ) ? $args[0] : 'list'; $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; switch ( $action ) { case 'list': $rows = array(); foreach ( self::posts( Station::POST_TYPE ) as $post ) { $public = Station::to_public( $post->ID ); $rows[] = array( 'id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status, 'stream' => $public && ! empty( $public['streams'] ) ? $public['streams'][0]['url'] : '', 'provider' => (string) get_post_meta( $post->ID, '_streamsera_metadata_detected', true ), ); } \WP_CLI\Utils\format_items( $format, $rows, array( 'id', 'title', 'status', 'stream', 'provider' ) ); break; case 'get': $id = isset( $args[1] ) ? absint( $args[1] ) : 0; $st = $id ? Station::to_public( $id ) : null; if ( ! $st ) { WP_CLI::error( __( 'Station not found or not published.', 'streamsera' ) ); } if ( 'json' === $format ) { WP_CLI::line( wp_json_encode( $st, JSON_PRETTY_PRINT ) ); } else { $items = array(); foreach ( $st as $k => $v ) { $items[] = array( 'field' => $k, 'value' => is_scalar( $v ) ? (string) $v : wp_json_encode( $v ), ); } \WP_CLI\Utils\format_items( 'table', $items, array( 'field', 'value' ) ); } break; case 'import-csv': $file = isset( $args[1] ) ? self::csv_path( (string) $args[1] ) : ''; if ( ! $file ) { WP_CLI::error( __( 'CSV file not found. Give the path of a readable .csv file. Columns: title,stream_url[,slogan,website,genre,logo (attachment ID)].', 'streamsera' ) ); } $status = isset( $assoc_args['status'] ) && 'draft' === $assoc_args['status'] ? 'draft' : 'publish'; $skipped = 0; $created = self::import_csv( $file, $status, $skipped ); if ( $skipped ) { /* translators: %d: number of skipped CSV rows. */ WP_CLI::warning( sprintf( _n( 'Skipped %d row without a title or a valid http(s) stream URL.', 'Skipped %d rows without a title or a valid http(s) stream URL.', $skipped, 'streamsera' ), $skipped ) ); } /* translators: %d: number of stations imported. */ WP_CLI::success( sprintf( _n( 'Imported %d station.', 'Imported %d stations.', $created, 'streamsera' ), $created ) ); break; default: WP_CLI::error( __( 'Unknown action. Use list, get or import-csv.', 'streamsera' ) ); } } /** * Import stations from a CSV file (header row required). * * Rows without a title or a valid http(s) stream URL are skipped (a published station needs a stream). * * @param string $file Path. * @param string $status Post status. * @param int $skipped Set to the number of skipped rows. * @return int Created count. */ public static function import_csv( $file, $status = 'publish', &$skipped = 0 ) { $skipped = 0; $status = in_array( $status, array( 'publish', 'draft' ), true ) ? $status : 'draft'; $file = self::csv_path( (string) $file ); if ( ! $file ) { return 0; } try { $csv = new \SplFileObject( $file, 'r' ); } catch ( \Exception $e ) { return 0; } $csv->setCsvControl( ',', '"', '\\' ); $header = array_map( 'strtolower', array_map( 'trim', array_map( 'strval', (array) $csv->fgetcsv() ) ) ); // Spreadsheet "CSV UTF-8" exports start with a byte-order mark: drop it from the first column name. if ( isset( $header[0] ) ) { $header[0] = preg_replace( '/^\xEF\xBB\xBF/', '', (string) $header[0] ); } $created = 0; while ( ! $csv->eof() ) { $row = $csv->fgetcsv(); if ( ! is_array( $row ) || array( null ) === $row ) { continue; } $data = array(); foreach ( $header as $i => $col ) { $data[ $col ] = isset( $row[ $i ] ) ? trim( (string) $row[ $i ] ) : ''; } $url = isset( $data['stream_url'] ) ? $data['stream_url'] : ( isset( $data['url'] ) ? $data['url'] : '' ); // Only full http(s) URLs: esc_url_raw() would turn "notaurl" into "http://notaurl". $url = $url && preg_match( '#^https?://[^/\s]+#i', $url ) ? esc_url_raw( $url, array( 'http', 'https' ) ) : ''; if ( empty( $data['title'] ) || ! $url ) { ++$skipped; continue; } // Re-running an import updates the station with the same (entity-decoded) title instead of // duplicating it ("Rock & Roll" stored vs "Rock & Roll" in the CSV). $existing = streamsera_find_post_by_title( Station::POST_TYPE, $data['title'] ); $id = $existing ? (int) $existing->ID : wp_insert_post( wp_slash( array( 'post_type' => Station::POST_TYPE, 'post_title' => sanitize_text_field( streamsera_plain_text( $data['title'] ) ), 'post_status' => $status, ) ), true ); if ( is_wp_error( $id ) ) { continue; } update_post_meta( $id, '_streamsera_streams', array( array( 'url' => $url, 'label' => __( 'Main', 'streamsera' ), ), ) ); foreach ( array( 'slogan', 'website', 'genre' ) as $key ) { if ( ! empty( $data[ $key ] ) ) { update_post_meta( $id, '_streamsera_' . $key, wp_slash( 'website' === $key ? esc_url_raw( $data[ $key ] ) : sanitize_text_field( $data[ $key ] ) ) ); } } if ( ! empty( $data['logo'] ) && absint( $data['logo'] ) ) { update_post_meta( $id, '_streamsera_logo_id', absint( $data['logo'] ) ); } ++$created; } $csv = null; return $created; } /** * Validate a CSV path given on the command line: an existing, readable regular file with a * .csv extension. Returns its real path, or '' when it is not acceptable. * * @param string $path Path. * @return string */ private static function csv_path( $path ) { $path = trim( $path ); if ( '' === $path || 'csv' !== strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) ) { return ''; } $real = realpath( $path ); return $real && is_file( $real ) && is_readable( $real ) ? $real : ''; } /** * Validate the export target: a .json file in an existing, writable folder. An existing file * is only replaced with --force (never a directory, a symlink or a non-.json file). * * @param string $path Path given on the command line. * @param bool $force Replace an existing file. * @return string|\WP_Error Absolute path. */ private static function export_path( $path, $force ) { $path = trim( $path ); if ( '' === $path || 'json' !== strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ) ) { return new \WP_Error( 'streamsera_export_path', __( 'The export file must end in .json.', 'streamsera' ) ); } $dir = realpath( dirname( $path ) ); if ( ! $dir || ! is_dir( $dir ) || ! wp_is_writable( $dir ) ) { return new \WP_Error( 'streamsera_export_path', __( 'The folder for the export file does not exist or is not writable.', 'streamsera' ) ); } $file = trailingslashit( $dir ) . sanitize_file_name( basename( $path ) ); if ( is_link( $file ) || is_dir( $file ) ) { return new \WP_Error( 'streamsera_export_path', __( 'The export path is not a regular file.', 'streamsera' ) ); } if ( file_exists( $file ) && ! $force ) { return new \WP_Error( 'streamsera_export_path', __( 'The export file already exists. Use --force to replace it.', 'streamsera' ) ); } return $file; } /* ----------------------------------------------------------------- players */ /** * Manage players. * * ## OPTIONS * * * : list * * [--format=] * : table, json, csv, yaml. Default: table. * * @param array $args Positional args. * @param array $assoc_args Flags. */ public function player( $args, $assoc_args ) { $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; $rows = array(); foreach ( self::posts( Player::POST_TYPE ) as $post ) { $config = Player::get_config( $post->ID, true ); $rows[] = array( 'id' => $post->ID, 'title' => $post->post_title, 'status' => $post->post_status, 'layout' => $config ? $config['layout'] : '', 'skin' => $config ? $config['skin'] : '', 'station' => $config ? (int) $config['station'] : 0, 'shortcode' => '[streamsera id="' . $post->ID . '"]', ); } \WP_CLI\Utils\format_items( $format, $rows, array( 'id', 'title', 'status', 'layout', 'skin', 'station', 'shortcode' ) ); } /* ------------------------------------------------------------------- cache */ /** * Manage caches. * * ## OPTIONS * * * : clear * * [--recent] * : Also clear add-on history (fires streamsera_clear_history). * * @param array $args Positional args. * @param array $assoc_args Flags. */ public function cache( $args, $assoc_args ) { unset( $args ); // Only the plugin's own caches; the rest of the object cache is left alone. \StreamSera\Api\Routes::clear_plugin_caches(); if ( isset( $assoc_args['recent'] ) ) { /** This action is documented in includes/Api/Routes.php */ do_action( 'streamsera_clear_history' ); } WP_CLI::success( __( 'StreamSera caches cleared.', 'streamsera' ) ); } /* ------------------------------------------------------------------ health */ /** * Check that every published station's stream answers. * * ## OPTIONS * * * : check * * [--format=] * : table, json, csv. Default: table. * * @param array $args Positional args. * @param array $assoc_args Flags. */ public function health( $args, $assoc_args ) { $format = isset( $assoc_args['format'] ) ? $assoc_args['format'] : 'table'; $rows = array(); $bad = 0; foreach ( self::posts( Station::POST_TYPE, 'publish' ) as $post ) { $public = Station::to_public( $post->ID ); $url = $public && ! empty( $public['streams'] ) ? $public['streams'][0]['url'] : ''; $start = microtime( true ); $ok = false; $code = 0; if ( $url ) { // Same guarded probe as the metadata poller: validated public host and stream ports, // redirects re-checked, and it stops after the headers (live streams never end). $res = Resolver::probe_headers( $url, 6, false ); $code = is_wp_error( $res ) ? 0 : (int) wp_remote_retrieve_response_code( $res ); $ok = $code >= 200 && $code < 400; } if ( ! $ok ) { ++$bad; } $np = Resolver::now_playing( $post->ID, false, true ); $rows[] = array( 'id' => $post->ID, 'title' => $post->post_title, 'ok' => $ok ? 'yes' : 'no', 'http' => $code, 'ms' => (int) round( ( microtime( true ) - $start ) * 1000 ), 'playing' => $np ? (string) $np['raw'] : '', ); } \WP_CLI\Utils\format_items( $format, $rows, array( 'id', 'title', 'ok', 'http', 'ms', 'playing' ) ); if ( $bad ) { /* translators: %d: number of unreachable stations. */ WP_CLI::warning( sprintf( _n( '%d station not reachable.', '%d stations not reachable.', $bad, 'streamsera' ), $bad ) ); } else { WP_CLI::success( __( 'All stations reachable.', 'streamsera' ) ); } } /* ------------------------------------------------------------------ export */ /** * Export settings, stations and players (plus add-on sections) as JSON. * * ## OPTIONS * * [] * : Write to this .json file instead of STDOUT. Its folder must exist; an existing file is only replaced with --force. * * [--force] * : Replace the file if it already exists. * * [--include-secrets] * : Keep credentials (provider API keys and any secrets add-ons store). Left out by default. * * @param array $args Positional args. * @param array $assoc_args Flags. */ public function export( $args, $assoc_args = array() ) { $secrets = (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'include-secrets', false ); $data = Admin_Tools::extend_export_data( \StreamSera\Api\Routes::export_data( $secrets ) ); $json = wp_json_encode( $data, JSON_PRETTY_PRINT ); if ( ! empty( $args[0] ) ) { $file = self::export_path( (string) $args[0], (bool) \WP_CLI\Utils\get_flag_value( $assoc_args, 'force', false ) ); if ( is_wp_error( $file ) ) { WP_CLI::error( $file->get_error_message() ); } global $wp_filesystem; if ( ! function_exists( 'WP_Filesystem' ) ) { require_once ABSPATH . 'wp-admin/includes/file.php'; } if ( ! WP_Filesystem() || ! $wp_filesystem || ! $wp_filesystem->put_contents( $file, (string) $json, FS_CHMOD_FILE ) ) { /* translators: %s: file path. */ WP_CLI::error( sprintf( __( 'Could not write %s.', 'streamsera' ), $file ) ); } /* translators: %s: file path. */ WP_CLI::success( sprintf( __( 'Exported to %s', 'streamsera' ), $file ) ); return; } WP_CLI::line( $json ); } /* ---------------------------------------------------------------- settings */ /** * Read or write plugin settings. * * ## OPTIONS * * * : get | set * * [] * : Setting key. Omit with `get` to print everything. * * [] * : Value for `set` (JSON is decoded: true, 15, "x", [1,2]). * * ## EXAMPLES * * wp streamsera settings get * wp streamsera settings get cache_ttl * wp streamsera settings set cache_ttl 30 * wp streamsera settings set listen_enabled true * * @param array $args Positional args. */ public function settings( $args ) { $action = isset( $args[0] ) ? $args[0] : 'get'; $settings = array_merge( \StreamSera\Installation::defaults(), (array) get_option( 'streamsera_settings', array() ) ); if ( 'get' === $action ) { if ( empty( $args[1] ) ) { WP_CLI::line( wp_json_encode( $settings, JSON_PRETTY_PRINT ) ); return; } $key = sanitize_key( $args[1] ); if ( ! array_key_exists( $key, $settings ) ) { /* translators: %s: setting key. */ WP_CLI::error( sprintf( __( 'Unknown setting: %s', 'streamsera' ), $key ) ); } WP_CLI::line( (string) wp_json_encode( $settings[ $key ] ) ); return; } if ( 'set' === $action ) { if ( empty( $args[1] ) || ! isset( $args[2] ) ) { WP_CLI::error( __( 'Usage: wp streamsera settings set ', 'streamsera' ) ); } $key = sanitize_key( $args[1] ); $decoded = json_decode( $args[2], true ); $value = null === $decoded && 'null' !== $args[2] ? $args[2] : $decoded; $current = (array) get_option( 'streamsera_settings', array() ); $input = array_merge( $settings, array( $key => $value ) ); /** This filter is documented in includes/Api/Routes.php */ $next = apply_filters( 'streamsera_save_settings', \StreamSera\Api\Routes::sanitize_settings( $input, $current ), $input ); if ( ! array_key_exists( $key, $next ) ) { /* translators: %s: setting key. */ WP_CLI::error( sprintf( __( 'Unknown setting: %s', 'streamsera' ), $key ) ); } update_option( 'streamsera_settings', $next ); WP_CLI::success( sprintf( '%s = %s', $key, wp_json_encode( $next[ $key ] ) ) ); return; } WP_CLI::error( __( 'Unknown action. Use get or set.', 'streamsera' ) ); } /** * Posts helper. * * @param string $type Post type. * @param string|array $status Status. * @return \WP_Post[] */ private static function posts( $type, $status = 'any' ) { return get_posts( array( 'post_type' => $type, 'numberposts' => -1, 'post_status' => $status, 'orderby' => 'title', 'order' => 'ASC', ) ); } }