Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
93.75% covered (success)
93.75%
330 / 352
67.86% covered (warning)
67.86%
19 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel_Data
93.75% covered (success)
93.75%
330 / 352
67.86% covered (warning)
67.86%
19 / 28
180.31
0.00% covered (danger)
0.00%
0 / 1
 reset
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 videos
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
5
 cache_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_full_list
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 query_args
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
4.01
 query_page
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
3
 video_count
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 query_videos
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 record
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
11.17
 latest_video
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 video_views
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 fetch_video_views
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
4.03
 parse_video_plays
80.95% covered (warning)
80.95%
17 / 21
0.00% covered (danger)
0.00%
0 / 1
17.77
 with_views
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 browse_request
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
10
 browse
100.00% covered (success)
100.00%
50 / 50
100.00% covered (success)
100.00%
1 / 1
9
 browse_page
91.67% covered (success)
91.67%
33 / 36
0.00% covered (danger)
0.00%
0 / 1
13.10
 matches_length
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
7.29
 video_details
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
17
 handle
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 subscriber_count
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 fetch_subscriber_count
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
6.73
 parse_subscriber_counts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
12
 total_views
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 joined
77.78% covered (warning)
77.78%
14 / 18
0.00% covered (danger)
0.00%
0 / 1
9.89
 compact_number
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 ago
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 views_and_age
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
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
8namespace Automattic\Jetpack\VideoPress;
9
10use Automattic\Jetpack\Connection\Client;
11use 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 */
20class 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}