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