Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.25% covered (warning)
79.25%
275 / 347
39.29% covered (danger)
39.29%
11 / 28
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel_Data
79.25% covered (warning)
79.25%
275 / 347
39.29% covered (danger)
39.29%
11 / 28
440.36
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
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
5.01
 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
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 query_videos
100.00% covered (success)
100.00%
8 / 8
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
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
9.02
 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
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 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
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
306
 handle
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
12
 subscriber_count
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
20
 fetch_subscriber_count
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
42
 parse_subscriber_counts
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
12
 total_views
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 joined
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
90
 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     * 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}