Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
93.75% |
330 / 352 |
|
67.86% |
19 / 28 |
CRAP | |
0.00% |
0 / 1 |
| Channel_Data | |
93.75% |
330 / 352 |
|
67.86% |
19 / 28 |
180.31 | |
0.00% |
0 / 1 |
| reset | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| videos | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
5 | |||
| cache_key | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| has_full_list | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| query_args | |
92.86% |
13 / 14 |
|
0.00% |
0 / 1 |
4.01 | |||
| query_page | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
3 | |||
| video_count | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| query_videos | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| record | |
88.89% |
16 / 18 |
|
0.00% |
0 / 1 |
11.17 | |||
| latest_video | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
5 | |||
| video_views | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
9 | |||
| fetch_video_views | |
87.50% |
14 / 16 |
|
0.00% |
0 / 1 |
4.03 | |||
| parse_video_plays | |
80.95% |
17 / 21 |
|
0.00% |
0 / 1 |
17.77 | |||
| with_views | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| browse_request | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
10 | |||
| browse | |
100.00% |
50 / 50 |
|
100.00% |
1 / 1 |
9 | |||
| browse_page | |
91.67% |
33 / 36 |
|
0.00% |
0 / 1 |
13.10 | |||
| matches_length | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
7.29 | |||
| video_details | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
17 | |||
| handle | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
3 | |||
| subscriber_count | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
4 | |||
| fetch_subscriber_count | |
72.73% |
8 / 11 |
|
0.00% |
0 / 1 |
6.73 | |||
| parse_subscriber_counts | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
12 | |||
| total_views | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| joined | |
77.78% |
14 / 18 |
|
0.00% |
0 / 1 |
9.89 | |||
| compact_number | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
4 | |||
| ago | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| views_and_age | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
4 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * VideoPress Channel data: the site's videos, their views and the channel's numbers. |
| 4 | * |
| 5 | * @package automattic/jetpack-videopress |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack\VideoPress; |
| 9 | |
| 10 | use Automattic\Jetpack\Connection\Client; |
| 11 | use WP_Post; |
| 12 | |
| 13 | /** |
| 14 | * Reads what the channel blocks show: the VideoPress videos in the media |
| 15 | * library ( straight from the posts table, as WordPress.com Simple has no |
| 16 | * wp/v2 routes on page renders ), their play counts from Jetpack Stats, and |
| 17 | * the channel's totals. Nothing is written to the blog except short-lived |
| 18 | * caches of the play and subscriber counts. |
| 19 | */ |
| 20 | class Channel_Data { |
| 21 | |
| 22 | /** |
| 23 | * Maximum attachments read in one batch of a full-library query. |
| 24 | * |
| 25 | * @var int |
| 26 | */ |
| 27 | const MAX_VIDEOS = 1000; |
| 28 | |
| 29 | /** |
| 30 | * Object-cache group and lifetime of the full video list. Keys carry the blog |
| 31 | * ID and the posts' last-changed stamp, so an upload or edit makes a new key. |
| 32 | */ |
| 33 | const CACHE_GROUP = 'videopress_channel'; |
| 34 | const CACHE_TTL = DAY_IN_SECONDS; |
| 35 | const CACHE_VERSION = 2; |
| 36 | |
| 37 | /** |
| 38 | * Transient caching the play counts, and how long. |
| 39 | */ |
| 40 | const VIEWS_TRANSIENT = 'videopress_channel_video_views'; |
| 41 | const VIEWS_TTL = HOUR_IN_SECONDS; |
| 42 | const VIEWS_ERROR_TTL = 10 * MINUTE_IN_SECONDS; |
| 43 | const SUBSCRIBERS_TRANSIENT = 'videopress_channel_subscribers'; |
| 44 | const SUBSCRIBERS_TTL = HOUR_IN_SECONDS; |
| 45 | const SUBSCRIBERS_ERROR_TTL = 10 * MINUTE_IN_SECONDS; |
| 46 | const SHORT_MAX_MS = 1200000; // Under 20 minutes. |
| 47 | const LONG_MIN_MS = 2700000; // Over 45 minutes. |
| 48 | const BROWSE_SORTS = array( 'newest', 'popular', 'oldest' ); |
| 49 | const BROWSE_LENGTHS = array( 'any', 'short', 'mid', 'long' ); |
| 50 | const SEARCH_MAX_LENGTH = 100; |
| 51 | const QUERY_ARG_SEARCH = 'videos-q'; |
| 52 | const QUERY_ARG_SORT = 'videos-sort'; |
| 53 | const QUERY_ARG_LENGTH = 'videos-length'; |
| 54 | const QUERY_ARG_PAGE = 'videos-page'; |
| 55 | const DEFAULT_BROWSE_PAGE = 1; |
| 56 | const COMPACT_THOUSAND = 1000; |
| 57 | const COMPACT_TEN_THOUSAND = 10000; |
| 58 | const COMPACT_MILLION = 1000000; |
| 59 | const DEFAULT_HANDLE_PREFIX = '@'; |
| 60 | |
| 61 | /** |
| 62 | * Per-request cache of the video lists, keyed by blog ID and then search term. |
| 63 | * |
| 64 | * @var array<int, array<string, array[]>> |
| 65 | */ |
| 66 | private static $videos = array(); |
| 67 | |
| 68 | /** |
| 69 | * Per-request cache of the play counts, keyed by blog ID. |
| 70 | * |
| 71 | * @var array<int, array<int, int>> |
| 72 | */ |
| 73 | private static $views = array(); |
| 74 | |
| 75 | /** |
| 76 | * Forget the per-request caches ( tests, and after uploads in long requests ). |
| 77 | * |
| 78 | * @return void |
| 79 | */ |
| 80 | public static function reset() { |
| 81 | self::$videos = array(); |
| 82 | self::$views = array(); |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * The site's VideoPress videos, newest first. |
| 87 | * |
| 88 | * @param string $search Optional search term, matched against the attachments. |
| 89 | * |
| 90 | * @return array[] Records with id, guid, durationMs, height and publishedAt ( unix time ). |
| 91 | */ |
| 92 | public static function videos( $search = '' ) { |
| 93 | $search = is_string( $search ) ? trim( $search ) : ''; |
| 94 | $blog_id = get_current_blog_id(); |
| 95 | if ( isset( self::$videos[ $blog_id ][ $search ] ) ) { |
| 96 | return self::$videos[ $blog_id ][ $search ]; |
| 97 | } |
| 98 | |
| 99 | /** |
| 100 | * Short-circuits the channel's video list. |
| 101 | * |
| 102 | * @since 0.58.0 |
| 103 | * |
| 104 | * @param array[]|null $videos Records with id, guid, durationMs, height and publishedAt, newest first; null to query. |
| 105 | * @param string $search The search term, if any. |
| 106 | */ |
| 107 | $videos = apply_filters( 'videopress_channel_videos', null, $search ); |
| 108 | if ( ! is_array( $videos ) ) { |
| 109 | $key = self::cache_key( 'videos', $search ); |
| 110 | $videos = wp_cache_get( $key, self::CACHE_GROUP ); |
| 111 | if ( ! is_array( $videos ) ) { |
| 112 | $videos = self::query_videos( $search ); |
| 113 | wp_cache_set( $key, $videos, self::CACHE_GROUP, self::CACHE_TTL ); |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | self::$videos[ $blog_id ][ $search ] = $videos; |
| 118 | return $videos; |
| 119 | } |
| 120 | |
| 121 | /** |
| 122 | * Object-cache key for this blog's data, invalidated whenever a post changes. |
| 123 | * |
| 124 | * @param string $what What is cached. |
| 125 | * @param string $search Search term, if any. |
| 126 | * |
| 127 | * @return string |
| 128 | */ |
| 129 | public static function cache_key( $what, $search = '' ) { |
| 130 | return implode( ':', array( self::CACHE_VERSION, $what, get_current_blog_id(), wp_cache_get_last_changed( 'posts' ), md5( (string) $search ) ) ); |
| 131 | } |
| 132 | |
| 133 | /** |
| 134 | * Whether the full list for a search is already at hand ( this request or the |
| 135 | * object cache ), or is replaced by a filter, so reading it costs no query. |
| 136 | * |
| 137 | * @param string $search Search term. |
| 138 | * |
| 139 | * @return bool |
| 140 | */ |
| 141 | private static function has_full_list( $search ) { |
| 142 | return isset( self::$videos[ get_current_blog_id() ][ $search ] ) |
| 143 | || has_filter( 'videopress_channel_videos' ) |
| 144 | || is_array( wp_cache_get( self::cache_key( 'videos', $search ), self::CACHE_GROUP ) ); |
| 145 | } |
| 146 | |
| 147 | /** |
| 148 | * The query arguments shared by the full list and the paged queries. |
| 149 | * |
| 150 | * @param string $search Search term. |
| 151 | * @param string $order DESC ( newest first ) or ASC. |
| 152 | * |
| 153 | * @return array |
| 154 | */ |
| 155 | private static function query_args( $search, $order = 'DESC' ) { |
| 156 | $is_wpcom = defined( 'IS_WPCOM' ) && IS_WPCOM; |
| 157 | $args = array( |
| 158 | 'post_type' => 'attachment', |
| 159 | 'post_status' => 'inherit', |
| 160 | 'post_mime_type' => $is_wpcom ? 'video' : Channel::MIME_TYPE, |
| 161 | // The ID breaks date ties, so ASC is exactly the reverse of DESC. |
| 162 | 'orderby' => array( |
| 163 | 'date' => $order, |
| 164 | 'ID' => $order, |
| 165 | ), |
| 166 | 'suppress_filters' => false, |
| 167 | ); |
| 168 | if ( '' !== $search ) { |
| 169 | $args['s'] = $search; |
| 170 | } |
| 171 | return $args; |
| 172 | } |
| 173 | |
| 174 | /** |
| 175 | * One page of videos straight from SQL, for listings that need no full list. |
| 176 | * |
| 177 | * @param string $search Search term. |
| 178 | * @param string $order DESC or ASC. |
| 179 | * @param int $per_page Page size. |
| 180 | * @param int $page 1-based page. |
| 181 | * |
| 182 | * @return array{records: array[], total: int}|null Null when an attachment on the page is |
| 183 | * not a VideoPress video, so the SQL total cannot be trusted. |
| 184 | */ |
| 185 | private static function query_page( $search, $order, $per_page, $page ) { |
| 186 | $query = new \WP_Query( |
| 187 | array_merge( |
| 188 | self::query_args( $search, $order ), |
| 189 | array( |
| 190 | 'posts_per_page' => $per_page, |
| 191 | 'paged' => $page, |
| 192 | 'ignore_sticky_posts' => true, |
| 193 | ) |
| 194 | ) |
| 195 | ); |
| 196 | |
| 197 | $records = array(); |
| 198 | foreach ( $query->posts as $post ) { |
| 199 | $record = self::record( $post ); |
| 200 | if ( ! $record ) { |
| 201 | return null; |
| 202 | } |
| 203 | $records[] = $record; |
| 204 | } |
| 205 | return array( |
| 206 | 'records' => $records, |
| 207 | 'total' => (int) $query->found_posts, |
| 208 | ); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * How many videos the channel has, without reading the full list unless it is at hand. |
| 213 | * |
| 214 | * @return int |
| 215 | */ |
| 216 | public static function video_count() { |
| 217 | if ( self::has_full_list( '' ) ) { |
| 218 | return count( self::videos() ); |
| 219 | } |
| 220 | $page = self::query_page( '', 'DESC', 1, 1 ); |
| 221 | return null === $page ? count( self::videos() ) : $page['total']; |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Query the VideoPress attachments: by mime off WordPress.com Simple, and |
| 226 | * every video with a GUID in the platform's videos table on it. |
| 227 | * |
| 228 | * @param string $search Search term. |
| 229 | * |
| 230 | * @return array[] |
| 231 | */ |
| 232 | private static function query_videos( $search ) { |
| 233 | $args = self::query_args( $search ); |
| 234 | $args['posts_per_page'] = self::MAX_VIDEOS; |
| 235 | $args['paged'] = 1; |
| 236 | |
| 237 | $videos = array(); |
| 238 | do { |
| 239 | $posts = get_posts( $args ); |
| 240 | foreach ( $posts as $post ) { |
| 241 | $record = self::record( $post ); |
| 242 | if ( $record ) { |
| 243 | $videos[] = $record; |
| 244 | } |
| 245 | } |
| 246 | ++$args['paged']; |
| 247 | $batch_size = count( $posts ); |
| 248 | } while ( self::MAX_VIDEOS === $batch_size ); |
| 249 | return $videos; |
| 250 | } |
| 251 | |
| 252 | /** |
| 253 | * The listing record of one attachment, or null when it is not a VideoPress video. |
| 254 | * |
| 255 | * @param WP_Post|null $post The attachment. |
| 256 | * |
| 257 | * @return array|null |
| 258 | */ |
| 259 | public static function record( $post ) { |
| 260 | if ( ! $post instanceof WP_Post ) { |
| 261 | return null; |
| 262 | } |
| 263 | $guid = Channel::guid( $post ); |
| 264 | if ( '' === $guid ) { |
| 265 | return null; |
| 266 | } |
| 267 | |
| 268 | $meta = wp_get_attachment_metadata( $post->ID ); |
| 269 | $meta = is_array( $meta ) ? $meta : array(); |
| 270 | $videopress = isset( $meta['videopress'] ) && is_array( $meta['videopress'] ) ? $meta['videopress'] : array(); |
| 271 | $duration = $videopress['duration'] ?? ( isset( $meta['length'] ) && is_numeric( $meta['length'] ) ? (int) $meta['length'] * 1000 : 0 ); |
| 272 | $height = $meta['height'] ?? $videopress['height'] ?? 0; |
| 273 | $published = strtotime( $post->post_date_gmt . ' UTC' ); |
| 274 | |
| 275 | return array( |
| 276 | 'id' => (int) $post->ID, |
| 277 | 'guid' => $guid, |
| 278 | 'durationMs' => is_numeric( $duration ) ? max( 0, (int) $duration ) : 0, |
| 279 | 'height' => is_numeric( $height ) ? max( 0, (int) $height ) : 0, |
| 280 | 'publishedAt' => false === $published ? 0 : (int) $published, |
| 281 | ); |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * The newest video's attachment, or null. |
| 286 | * |
| 287 | * @return WP_Post|null |
| 288 | */ |
| 289 | public static function latest_video() { |
| 290 | $page = self::has_full_list( '' ) ? null : self::query_page( '', 'DESC', 1, 1 ); |
| 291 | $videos = null === $page ? self::videos() : $page['records']; |
| 292 | $post = $videos ? get_post( $videos[0]['id'] ) : null; |
| 293 | return $post instanceof WP_Post ? $post : null; |
| 294 | } |
| 295 | |
| 296 | /** |
| 297 | * Play counts per attachment ID. |
| 298 | * |
| 299 | * @return array<int, int> |
| 300 | */ |
| 301 | public static function video_views() { |
| 302 | $blog_id = get_current_blog_id(); |
| 303 | if ( isset( self::$views[ $blog_id ] ) ) { |
| 304 | return self::$views[ $blog_id ]; |
| 305 | } |
| 306 | |
| 307 | $views = get_transient( self::VIEWS_TRANSIENT ); |
| 308 | if ( ! is_array( $views ) ) { |
| 309 | $fetched = self::fetch_video_views(); |
| 310 | $views = is_array( $fetched ) ? $fetched : array(); |
| 311 | // A failed fetch is cached as no plays for a short while, so it is not retried on every render. |
| 312 | set_transient( self::VIEWS_TRANSIENT, $views, null === $fetched ? self::VIEWS_ERROR_TTL : self::VIEWS_TTL ); |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Filters the play counts the channel shows and sorts by. |
| 317 | * |
| 318 | * @since 0.58.0 |
| 319 | * |
| 320 | * @param array<int, int> $views Plays keyed by attachment ID. |
| 321 | */ |
| 322 | $views = apply_filters( 'videopress_channel_video_views', $views ); |
| 323 | |
| 324 | $clean = array(); |
| 325 | foreach ( is_array( $views ) ? $views : array() as $id => $count ) { |
| 326 | if ( is_numeric( $id ) && is_numeric( $count ) ) { |
| 327 | $clean[ (int) $id ] = max( 0, (int) $count ); |
| 328 | } |
| 329 | } |
| 330 | |
| 331 | self::$views[ $blog_id ] = $clean; |
| 332 | return $clean; |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Ask Jetpack Stats for the video plays, when the Stats package is present. |
| 337 | * |
| 338 | * @return array<int, int>|null Plays keyed by post ID; null on failure. |
| 339 | */ |
| 340 | private static function fetch_video_views() { |
| 341 | $stats_class = '\Automattic\Jetpack\Stats\WPCOM_Stats'; |
| 342 | if ( ! class_exists( $stats_class ) ) { |
| 343 | return array(); |
| 344 | } |
| 345 | |
| 346 | /** |
| 347 | * Filters the query the channel sends to the video plays stats endpoint. |
| 348 | * |
| 349 | * @since 0.58.0 |
| 350 | * |
| 351 | * @param array $args Endpoint query arguments. |
| 352 | */ |
| 353 | $args = apply_filters( |
| 354 | 'videopress_channel_video_plays_args', |
| 355 | array( |
| 356 | // With `summarize` the endpoint ignores `period` and reads `num` as days; it has no all-time option. |
| 357 | 'period' => 'day', |
| 358 | 'num' => 3650, |
| 359 | 'max' => 0, |
| 360 | 'summarize' => 1, |
| 361 | ) |
| 362 | ); |
| 363 | |
| 364 | $response = ( new $stats_class() )->get_video_plays( $args ); |
| 365 | if ( is_wp_error( $response ) || ! is_array( $response ) ) { |
| 366 | return null; |
| 367 | } |
| 368 | |
| 369 | return self::parse_video_plays( $response ); |
| 370 | } |
| 371 | |
| 372 | /** |
| 373 | * Collect attachment ID → plays from a `stats/video-plays` response. With `summarize` |
| 374 | * the rows sit in `days.summary.plays` ( `data` rows counting `views` with `complete_stats` ); |
| 375 | * without it each day has its own `plays` rows, which are added up. |
| 376 | * |
| 377 | * @param mixed $response Decoded response. |
| 378 | * |
| 379 | * @return array<int, int> |
| 380 | */ |
| 381 | public static function parse_video_plays( $response ) { |
| 382 | $response = json_decode( (string) wp_json_encode( $response, JSON_UNESCAPED_SLASHES ), true ); |
| 383 | if ( ! is_array( $response ) ) { |
| 384 | return array(); |
| 385 | } |
| 386 | $days = isset( $response['days'] ) && is_array( $response['days'] ) ? $response['days'] : array(); |
| 387 | if ( isset( $response['summary'] ) && is_array( $response['summary'] ) ) { |
| 388 | $periods = array( $response['summary'] ); |
| 389 | } elseif ( isset( $days['summary'] ) && is_array( $days['summary'] ) ) { |
| 390 | $periods = array( $days['summary'] ); |
| 391 | } else { |
| 392 | $periods = $days; |
| 393 | } |
| 394 | |
| 395 | $plays = array(); |
| 396 | foreach ( $periods as $period ) { |
| 397 | if ( ! is_array( $period ) ) { |
| 398 | continue; |
| 399 | } |
| 400 | $rows = $period['plays'] ?? $period['data'] ?? array(); |
| 401 | foreach ( is_array( $rows ) ? $rows : array() as $row ) { |
| 402 | $count = is_array( $row ) ? ( $row['plays'] ?? $row['views'] ?? null ) : null; |
| 403 | if ( ! isset( $row['post_id'] ) || ! is_numeric( $row['post_id'] ) || ! is_numeric( $count ) ) { |
| 404 | continue; |
| 405 | } |
| 406 | $id = (int) $row['post_id']; |
| 407 | $plays[ $id ] = ( $plays[ $id ] ?? 0 ) + max( 0, (int) $count ); |
| 408 | } |
| 409 | } |
| 410 | |
| 411 | return $plays; |
| 412 | } |
| 413 | |
| 414 | /** |
| 415 | * Add the play count to listing records. |
| 416 | * |
| 417 | * @param array[] $records Records with an id. |
| 418 | * |
| 419 | * @return array[] |
| 420 | */ |
| 421 | public static function with_views( array $records ) { |
| 422 | $views = self::video_views(); |
| 423 | foreach ( $records as &$record ) { |
| 424 | $record['views'] = $views[ (int) ( $record['id'] ?? 0 ) ] ?? 0; |
| 425 | } |
| 426 | unset( $record ); |
| 427 | return $records; |
| 428 | } |
| 429 | |
| 430 | /** |
| 431 | * The browsing state a request asks for. |
| 432 | * |
| 433 | * @param bool $read_filters Whether search, sort and length come from the request ( browse controls shown ). |
| 434 | * @param bool $read_page Whether the page comes from the request ( numbered pagination ). |
| 435 | * @param string $default_sort The block's own order. |
| 436 | * |
| 437 | * @return array{search: string, sort: string, length: string, page: int} |
| 438 | */ |
| 439 | public static function browse_request( $read_filters, $read_page, $default_sort = 'newest' ) { |
| 440 | // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only listing arguments, like core's paged and s. |
| 441 | $get = function ( $key ) { |
| 442 | return isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : ''; |
| 443 | }; |
| 444 | $state = array( |
| 445 | 'search' => '', |
| 446 | 'sort' => in_array( $default_sort, self::BROWSE_SORTS, true ) ? $default_sort : 'newest', |
| 447 | 'length' => 'any', |
| 448 | 'page' => self::DEFAULT_BROWSE_PAGE, |
| 449 | ); |
| 450 | |
| 451 | if ( $read_filters ) { |
| 452 | $state['search'] = function_exists( 'mb_substr' ) ? mb_substr( $get( self::QUERY_ARG_SEARCH ), 0, self::SEARCH_MAX_LENGTH ) : substr( $get( self::QUERY_ARG_SEARCH ), 0, self::SEARCH_MAX_LENGTH ); |
| 453 | $sort = $get( self::QUERY_ARG_SORT ); |
| 454 | $sort = 'latest' === $sort ? 'newest' : $sort; |
| 455 | if ( in_array( $sort, self::BROWSE_SORTS, true ) ) { |
| 456 | $state['sort'] = $sort; |
| 457 | } |
| 458 | $length = $get( self::QUERY_ARG_LENGTH ); |
| 459 | if ( in_array( $length, self::BROWSE_LENGTHS, true ) ) { |
| 460 | $state['length'] = $length; |
| 461 | } |
| 462 | } |
| 463 | if ( $read_page ) { |
| 464 | $state['page'] = max( 1, absint( $get( self::QUERY_ARG_PAGE ) ) ); |
| 465 | } |
| 466 | // phpcs:enable WordPress.Security.NonceVerification.Recommended |
| 467 | |
| 468 | return $state; |
| 469 | } |
| 470 | |
| 471 | /** |
| 472 | * One page of the channel's videos for a browsing state. |
| 473 | * |
| 474 | * @param array $state From browse_request(). |
| 475 | * @param int $per_page Page size. |
| 476 | * @param string[] $exclude GUIDs to leave out. |
| 477 | * @param bool $with_views Whether records need play counts. |
| 478 | * |
| 479 | * @return array{entries: array[], total: int, page: int, pages: int, start: int} |
| 480 | */ |
| 481 | public static function browse( array $state, $per_page, array $exclude = array(), $with_views = false ) { |
| 482 | $per_page = max( 1, (int) $per_page ); |
| 483 | $fast = self::browse_page( $state, $per_page, $exclude, $with_views ); |
| 484 | if ( null !== $fast ) { |
| 485 | return $fast; |
| 486 | } |
| 487 | $videos = self::videos( $state['search'] ?? '' ); |
| 488 | |
| 489 | if ( $exclude ) { |
| 490 | $videos = array_values( |
| 491 | array_filter( |
| 492 | $videos, |
| 493 | function ( $video ) use ( $exclude ) { |
| 494 | return ! in_array( $video['guid'], $exclude, true ); |
| 495 | } |
| 496 | ) |
| 497 | ); |
| 498 | } |
| 499 | |
| 500 | $length = $state['length'] ?? 'any'; |
| 501 | if ( 'any' !== $length ) { |
| 502 | $videos = array_values( |
| 503 | array_filter( |
| 504 | $videos, |
| 505 | function ( $video ) use ( $length ) { |
| 506 | return self::matches_length( (int) $video['durationMs'], $length ); |
| 507 | } |
| 508 | ) |
| 509 | ); |
| 510 | } |
| 511 | |
| 512 | $sort = $state['sort'] ?? 'newest'; |
| 513 | if ( $with_views || 'popular' === $sort ) { |
| 514 | $videos = self::with_views( $videos ); |
| 515 | } |
| 516 | if ( 'popular' === $sort ) { |
| 517 | // Most viewed first; equal counts keep upload order ( newest first ). |
| 518 | $position = array_flip( array_keys( $videos ) ); |
| 519 | uksort( |
| 520 | $videos, |
| 521 | function ( $a, $b ) use ( $videos, $position ) { |
| 522 | $diff = $videos[ $b ]['views'] - $videos[ $a ]['views']; |
| 523 | return 0 !== $diff ? $diff : $position[ $a ] - $position[ $b ]; |
| 524 | } |
| 525 | ); |
| 526 | $videos = array_values( $videos ); |
| 527 | } elseif ( 'oldest' === $sort ) { |
| 528 | $videos = array_reverse( $videos ); |
| 529 | } |
| 530 | |
| 531 | $total = count( $videos ); |
| 532 | $pages = max( 1, (int) ceil( $total / $per_page ) ); |
| 533 | $page = min( $pages, max( 1, (int) ( $state['page'] ?? 1 ) ) ); |
| 534 | $start = ( $page - 1 ) * $per_page; |
| 535 | |
| 536 | return array( |
| 537 | 'entries' => array_slice( $videos, $start, $per_page ), |
| 538 | 'total' => $total, |
| 539 | 'page' => $page, |
| 540 | 'pages' => $pages, |
| 541 | 'start' => $start, |
| 542 | ); |
| 543 | } |
| 544 | |
| 545 | /** |
| 546 | * A browse() page straight from SQL: newest or oldest first, any length, and at |
| 547 | * most a few excluded videos on the first page. Null when the full list is |
| 548 | * needed ( popular, a length filter, exclusions elsewhere ) or already at hand. |
| 549 | * |
| 550 | * @param array $state From browse_request(). |
| 551 | * @param int $per_page Page size. |
| 552 | * @param string[] $exclude GUIDs to leave out. |
| 553 | * @param bool $with_views Whether records need play counts. |
| 554 | * |
| 555 | * @return array|null Same shape as browse(). |
| 556 | */ |
| 557 | private static function browse_page( array $state, $per_page, array $exclude, $with_views ) { |
| 558 | $search = trim( (string) ( $state['search'] ?? '' ) ); |
| 559 | $sort = $state['sort'] ?? 'newest'; |
| 560 | $page = max( 1, (int) ( $state['page'] ?? 1 ) ); |
| 561 | if ( ! in_array( $sort, array( 'newest', 'oldest' ), true ) |
| 562 | || 'any' !== ( $state['length'] ?? 'any' ) |
| 563 | || ( $exclude && 1 !== $page ) |
| 564 | || self::has_full_list( $search ) |
| 565 | ) { |
| 566 | return null; |
| 567 | } |
| 568 | |
| 569 | $result = self::query_page( $search, 'oldest' === $sort ? 'ASC' : 'DESC', $per_page + count( $exclude ), $page ); |
| 570 | if ( null === $result ) { |
| 571 | return null; |
| 572 | } |
| 573 | |
| 574 | $records = $result['records']; |
| 575 | $total = $result['total']; |
| 576 | if ( $exclude ) { |
| 577 | $kept = array_values( |
| 578 | array_filter( |
| 579 | $records, |
| 580 | function ( $video ) use ( $exclude ) { |
| 581 | return ! in_array( $video['guid'], $exclude, true ); |
| 582 | } |
| 583 | ) |
| 584 | ); |
| 585 | // An excluded video beyond this window would make the total wrong. |
| 586 | if ( count( $records ) - count( $kept ) !== count( array_unique( $exclude ) ) && count( $records ) < $total ) { |
| 587 | return null; |
| 588 | } |
| 589 | $total -= count( $records ) - count( $kept ); |
| 590 | $records = array_slice( $kept, 0, $per_page ); |
| 591 | } |
| 592 | |
| 593 | $pages = max( 1, (int) ceil( $total / $per_page ) ); |
| 594 | if ( $page > $pages ) { |
| 595 | return null; |
| 596 | } |
| 597 | |
| 598 | return array( |
| 599 | 'entries' => $with_views ? self::with_views( $records ) : $records, |
| 600 | 'total' => $total, |
| 601 | 'page' => $page, |
| 602 | 'pages' => $pages, |
| 603 | 'start' => ( $page - 1 ) * $per_page, |
| 604 | ); |
| 605 | } |
| 606 | |
| 607 | /** |
| 608 | * Whether a duration falls in a length bucket. Unknown durations match only "any". |
| 609 | * |
| 610 | * @param int $duration_ms Duration. |
| 611 | * @param string $length One of BROWSE_LENGTHS. |
| 612 | * |
| 613 | * @return bool |
| 614 | */ |
| 615 | public static function matches_length( $duration_ms, $length ) { |
| 616 | if ( 'any' === $length ) { |
| 617 | return true; |
| 618 | } |
| 619 | if ( $duration_ms <= 0 ) { |
| 620 | return false; |
| 621 | } |
| 622 | switch ( $length ) { |
| 623 | case 'short': |
| 624 | return $duration_ms < self::SHORT_MAX_MS; |
| 625 | case 'mid': |
| 626 | return $duration_ms >= self::SHORT_MAX_MS && $duration_ms <= self::LONG_MIN_MS; |
| 627 | case 'long': |
| 628 | return $duration_ms > self::LONG_MIN_MS; |
| 629 | } |
| 630 | return true; |
| 631 | } |
| 632 | |
| 633 | /** |
| 634 | * A video's display details from VideoPress, with the attachment as fallback. |
| 635 | * |
| 636 | * @param string $guid VideoPress GUID. |
| 637 | * @param int|WP_Post|null $attachment The attachment, when known. |
| 638 | * |
| 639 | * @return array{title: string, description: string, poster: string} |
| 640 | */ |
| 641 | public static function video_details( $guid, $attachment = null ) { |
| 642 | $details = function_exists( 'videopress_get_video_details' ) && is_string( $guid ) && '' !== $guid ? videopress_get_video_details( $guid ) : null; |
| 643 | $details = is_object( $details ) && ! is_wp_error( $details ) ? $details : null; |
| 644 | $post = null !== $attachment ? get_post( $attachment ) : ( $guid ? get_post( Channel::attachment_id( $guid ) ) : null ); |
| 645 | $post = $post instanceof WP_Post ? $post : null; |
| 646 | |
| 647 | $title = $details && ! empty( $details->title ) ? (string) $details->title : ( $post ? get_the_title( $post ) : '' ); |
| 648 | $description = $details && ! empty( $details->description ) ? (string) $details->description : ( $post ? (string) $post->post_content : '' ); |
| 649 | $poster = $details && ! empty( $details->poster ) ? (string) $details->poster : ''; |
| 650 | |
| 651 | return array( |
| 652 | 'title' => $title, |
| 653 | 'description' => $description, |
| 654 | 'poster' => $poster, |
| 655 | ); |
| 656 | } |
| 657 | |
| 658 | /** |
| 659 | * The channel's handle, e.g. "@journeythroughforever". |
| 660 | * |
| 661 | * @return string |
| 662 | */ |
| 663 | public static function handle() { |
| 664 | $host = (string) wp_parse_url( home_url(), PHP_URL_HOST ); |
| 665 | $label = $host ? strtok( preg_replace( '/^www\./', '', $host ), '.' ) : ''; |
| 666 | |
| 667 | /** |
| 668 | * Filters the channel handle shown in the channel details. |
| 669 | * |
| 670 | * @since 0.58.0 |
| 671 | * |
| 672 | * @param string $handle "@" plus the first label of the site's host; empty for none. |
| 673 | */ |
| 674 | return (string) apply_filters( 'videopress_channel_handle', $label ? self::DEFAULT_HANDLE_PREFIX . $label : '' ); |
| 675 | } |
| 676 | |
| 677 | /** |
| 678 | * Subscriber count, or null when unknown. |
| 679 | * |
| 680 | * @return int|null |
| 681 | */ |
| 682 | public static function subscriber_count() { |
| 683 | $count = function_exists( 'wpcom_subs_total_for_blog' ) ? wpcom_subs_total_for_blog() : self::fetch_subscriber_count(); |
| 684 | |
| 685 | /** |
| 686 | * Filters the subscriber count shown in the channel details: WordPress.com's count, read |
| 687 | * directly on WordPress.com and through the site's connection elsewhere. |
| 688 | * |
| 689 | * @since 0.58.0 |
| 690 | * |
| 691 | * @param int|null $count Subscribers, or null when unknown. |
| 692 | */ |
| 693 | $count = apply_filters( 'videopress_channel_subscriber_count', is_numeric( $count ) ? (int) $count : null ); |
| 694 | |
| 695 | return is_numeric( $count ) ? max( 0, (int) $count ) : null; |
| 696 | } |
| 697 | |
| 698 | /** |
| 699 | * Subscribers ( email subscribers plus social followers, like the Subscribe block counts them ) |
| 700 | * from WordPress.com through the site's connection, cached for an hour. |
| 701 | * |
| 702 | * @return int|null Null when the site is not connected or the request failed. |
| 703 | */ |
| 704 | private static function fetch_subscriber_count() { |
| 705 | $cached = get_transient( self::SUBSCRIBERS_TRANSIENT ); |
| 706 | if ( is_array( $cached ) && array_key_exists( 'count', $cached ) ) { |
| 707 | return is_numeric( $cached['count'] ) ? (int) $cached['count'] : null; |
| 708 | } |
| 709 | |
| 710 | $blog_id = (int) VideoPressToken::blog_id(); |
| 711 | $count = $blog_id |
| 712 | ? self::parse_subscriber_counts( |
| 713 | Client::wpcom_json_api_request_as_blog( sprintf( '/sites/%d/subscribers/counts', $blog_id ), '2', array( 'timeout' => 5 ), null, 'wpcom' ) |
| 714 | ) |
| 715 | : null; |
| 716 | |
| 717 | set_transient( self::SUBSCRIBERS_TRANSIENT, array( 'count' => $count ), null === $count ? self::SUBSCRIBERS_ERROR_TTL : self::SUBSCRIBERS_TTL ); |
| 718 | return $count; |
| 719 | } |
| 720 | |
| 721 | /** |
| 722 | * The subscriber total in a `/sites/%d/subscribers/counts` response: |
| 723 | * `{ "counts": { "total_subscribers": 42, "social_followers": 9, … } }`. |
| 724 | * |
| 725 | * @param mixed $response HTTP response array or WP_Error. |
| 726 | * |
| 727 | * @return int|null |
| 728 | */ |
| 729 | public static function parse_subscriber_counts( $response ) { |
| 730 | if ( is_wp_error( $response ) || ! is_array( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) { |
| 731 | return null; |
| 732 | } |
| 733 | $body = json_decode( (string) wp_remote_retrieve_body( $response ), true ); |
| 734 | $counts = is_array( $body ) && isset( $body['counts'] ) && is_array( $body['counts'] ) ? $body['counts'] : null; |
| 735 | if ( ! $counts || ! isset( $counts['total_subscribers'] ) || ! is_numeric( $counts['total_subscribers'] ) ) { |
| 736 | return null; |
| 737 | } |
| 738 | $followers = isset( $counts['social_followers'] ) && is_numeric( $counts['social_followers'] ) ? (int) $counts['social_followers'] : 0; |
| 739 | return max( 0, (int) $counts['total_subscribers'] + $followers ); |
| 740 | } |
| 741 | |
| 742 | /** |
| 743 | * Total plays of the channel's videos. |
| 744 | * |
| 745 | * @return int |
| 746 | */ |
| 747 | public static function total_views() { |
| 748 | $ids = array_flip( array_column( self::videos(), 'id' ) ); |
| 749 | $total = 0; |
| 750 | foreach ( self::video_views() as $id => $count ) { |
| 751 | if ( isset( $ids[ $id ] ) ) { |
| 752 | $total += $count; |
| 753 | } |
| 754 | } |
| 755 | return $total; |
| 756 | } |
| 757 | |
| 758 | /** |
| 759 | * When the channel started: the site's registration date on a network, |
| 760 | * else its oldest published post. Unix time, 0 when unknown. |
| 761 | * |
| 762 | * @return int |
| 763 | */ |
| 764 | public static function joined() { |
| 765 | $time = 0; |
| 766 | if ( is_multisite() && function_exists( 'get_site' ) ) { |
| 767 | $site = get_site(); |
| 768 | if ( $site && ! empty( $site->registered ) && '0000-00-00 00:00:00' !== $site->registered ) { |
| 769 | $time = (int) strtotime( $site->registered . ' UTC' ); |
| 770 | } |
| 771 | } |
| 772 | if ( ! $time ) { |
| 773 | $oldest = get_posts( |
| 774 | array( |
| 775 | 'post_type' => 'any', |
| 776 | 'post_status' => 'publish', |
| 777 | 'orderby' => 'date', |
| 778 | 'order' => 'ASC', |
| 779 | 'posts_per_page' => 1, |
| 780 | ) |
| 781 | ); |
| 782 | if ( $oldest && $oldest[0] instanceof WP_Post ) { |
| 783 | $time = (int) strtotime( $oldest[0]->post_date_gmt . ' UTC' ); |
| 784 | } |
| 785 | } |
| 786 | |
| 787 | /** |
| 788 | * Filters when the channel started, shown as "Joined". |
| 789 | * |
| 790 | * @since 0.58.0 |
| 791 | * |
| 792 | * @param int $time Unix time, 0 when unknown. |
| 793 | */ |
| 794 | return max( 0, (int) apply_filters( 'videopress_channel_joined', $time ) ); |
| 795 | } |
| 796 | |
| 797 | /** |
| 798 | * A compact count: 950, 1.2K, 48K, 1.2M. |
| 799 | * |
| 800 | * @param int $count The number. |
| 801 | * |
| 802 | * @return string |
| 803 | */ |
| 804 | public static function compact_number( $count ) { |
| 805 | $count = max( 0, (int) $count ); |
| 806 | if ( $count >= self::COMPACT_MILLION ) { |
| 807 | /* translators: %s: a number of millions, e.g. 1.2 */ |
| 808 | return sprintf( __( '%sM', 'jetpack-videopress-pkg' ), number_format_i18n( $count / self::COMPACT_MILLION, 1 ) ); |
| 809 | } |
| 810 | if ( $count >= self::COMPACT_TEN_THOUSAND ) { |
| 811 | /* translators: %s: a number of thousands, e.g. 48 */ |
| 812 | return sprintf( __( '%sK', 'jetpack-videopress-pkg' ), number_format_i18n( floor( $count / self::COMPACT_THOUSAND ) ) ); |
| 813 | } |
| 814 | if ( $count >= self::COMPACT_THOUSAND ) { |
| 815 | /* translators: %s: a number of thousands, e.g. 1.2 */ |
| 816 | return sprintf( __( '%sK', 'jetpack-videopress-pkg' ), number_format_i18n( $count / self::COMPACT_THOUSAND, 1 ) ); |
| 817 | } |
| 818 | return number_format_i18n( $count ); |
| 819 | } |
| 820 | |
| 821 | /** |
| 822 | * "3 days ago" for a unix time. |
| 823 | * |
| 824 | * @param int $time Unix time. |
| 825 | * |
| 826 | * @return string Empty when unknown. |
| 827 | */ |
| 828 | public static function ago( $time ) { |
| 829 | $time = (int) $time; |
| 830 | if ( $time <= 0 ) { |
| 831 | return ''; |
| 832 | } |
| 833 | /* translators: %s: a time span, e.g. "3 days". */ |
| 834 | return sprintf( __( '%s ago', 'jetpack-videopress-pkg' ), human_time_diff( $time, time() ) ); |
| 835 | } |
| 836 | |
| 837 | /** |
| 838 | * "12K views · 3 days ago", from a listing record. |
| 839 | * |
| 840 | * @param array $record Record with views and publishedAt. |
| 841 | * @param bool $full_count Whether to print the full view count ( 12,345 ) instead of the compact one. |
| 842 | * |
| 843 | * @return string |
| 844 | */ |
| 845 | public static function views_and_age( array $record, $full_count = false ) { |
| 846 | $parts = array(); |
| 847 | if ( isset( $record['views'] ) ) { |
| 848 | $views = (int) $record['views']; |
| 849 | $number = $full_count ? number_format_i18n( $views ) : self::compact_number( $views ); |
| 850 | $parts[] = sprintf( |
| 851 | /* translators: %s: number of views, e.g. "12K". */ |
| 852 | _n( '%s view', '%s views', $views, 'jetpack-videopress-pkg' ), |
| 853 | $number |
| 854 | ); |
| 855 | } |
| 856 | $ago = self::ago( (int) ( $record['publishedAt'] ?? 0 ) ); |
| 857 | if ( '' !== $ago ) { |
| 858 | $parts[] = $ago; |
| 859 | } |
| 860 | return implode( ' · ', $parts ); |
| 861 | } |
| 862 | } |