Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.15% covered (success)
94.15%
338 / 359
72.41% covered (warning)
72.41%
21 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel_Data
94.15% covered (success)
94.15%
338 / 359
72.41% covered (warning)
72.41%
21 / 29
184.34
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
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 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%
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
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%
11 / 11
100.00% covered (success)
100.00%
1 / 1
5
 browse_results
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     * 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 0.58.0
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            $args['videopress_transcript_search'] = Transcript_Index::is_search_enabled();
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
236        $videos = array();
237        foreach ( get_posts( $args ) as $post ) {
238            $record = self::record( $post );
239            if ( $record ) {
240                $videos[] = $record;
241            }
242        }
243        return $videos;
244    }
245
246    /**
247     * The listing record of one attachment, or null when it is not a VideoPress video.
248     *
249     * @param WP_Post|null $post The attachment.
250     *
251     * @return array|null
252     */
253    public static function record( $post ) {
254        if ( ! $post instanceof WP_Post ) {
255            return null;
256        }
257        $guid = Channel::guid( $post );
258        if ( '' === $guid ) {
259            return null;
260        }
261
262        $meta       = wp_get_attachment_metadata( $post->ID );
263        $meta       = is_array( $meta ) ? $meta : array();
264        $videopress = isset( $meta['videopress'] ) && is_array( $meta['videopress'] ) ? $meta['videopress'] : array();
265        $duration   = $videopress['duration'] ?? ( isset( $meta['length'] ) && is_numeric( $meta['length'] ) ? (int) $meta['length'] * 1000 : 0 );
266        $height     = $meta['height'] ?? $videopress['height'] ?? 0;
267        $published  = strtotime( $post->post_date_gmt . ' UTC' );
268
269        return array(
270            'id'          => (int) $post->ID,
271            'guid'        => $guid,
272            'durationMs'  => is_numeric( $duration ) ? max( 0, (int) $duration ) : 0,
273            'height'      => is_numeric( $height ) ? max( 0, (int) $height ) : 0,
274            'publishedAt' => false === $published ? 0 : (int) $published,
275        );
276    }
277
278    /**
279     * The newest video's attachment, or null.
280     *
281     * @return WP_Post|null
282     */
283    public static function latest_video() {
284        $page   = self::has_full_list( '' ) ? null : self::query_page( '', 'DESC', 1, 1 );
285        $videos = null === $page ? self::videos() : $page['records'];
286        $post   = $videos ? get_post( $videos[0]['id'] ) : null;
287        return $post instanceof WP_Post ? $post : null;
288    }
289
290    /**
291     * Play counts per attachment ID.
292     *
293     * @return array<int, int>
294     */
295    public static function video_views() {
296        $blog_id = get_current_blog_id();
297        if ( isset( self::$views[ $blog_id ] ) ) {
298            return self::$views[ $blog_id ];
299        }
300
301        $views = get_transient( self::VIEWS_TRANSIENT );
302        if ( ! is_array( $views ) ) {
303            $fetched = self::fetch_video_views();
304            $views   = is_array( $fetched ) ? $fetched : array();
305            // A failed fetch is cached as no plays for a short while, so it is not retried on every render.
306            set_transient( self::VIEWS_TRANSIENT, $views, null === $fetched ? self::VIEWS_ERROR_TTL : self::VIEWS_TTL );
307        }
308
309        /**
310         * Filters the play counts the channel shows and sorts by.
311         *
312         * @since 0.58.0
313         *
314         * @param array<int, int> $views Plays keyed by attachment ID.
315         */
316        $views = apply_filters( 'videopress_channel_video_views', $views );
317
318        $clean = array();
319        foreach ( is_array( $views ) ? $views : array() as $id => $count ) {
320            if ( is_numeric( $id ) && is_numeric( $count ) ) {
321                $clean[ (int) $id ] = max( 0, (int) $count );
322            }
323        }
324
325        self::$views[ $blog_id ] = $clean;
326        return $clean;
327    }
328
329    /**
330     * Ask Jetpack Stats for the video plays, when the Stats package is present.
331     *
332     * @return array<int, int>|null Plays keyed by post ID; null on failure.
333     */
334    private static function fetch_video_views() {
335        $stats_class = '\Automattic\Jetpack\Stats\WPCOM_Stats';
336        if ( ! class_exists( $stats_class ) ) {
337            return array();
338        }
339
340        /**
341         * Filters the query the channel sends to the video plays stats endpoint.
342         *
343         * @since 0.58.0
344         *
345         * @param array $args Endpoint query arguments.
346         */
347        $args = apply_filters(
348            'videopress_channel_video_plays_args',
349            array(
350                // With `summarize` the endpoint ignores `period` and reads `num` as days; it has no all-time option.
351                'period'    => 'day',
352                'num'       => 3650,
353                'max'       => 0,
354                'summarize' => 1,
355            )
356        );
357
358        $response = ( new $stats_class() )->get_video_plays( $args );
359        if ( is_wp_error( $response ) || ! is_array( $response ) ) {
360            return null;
361        }
362
363        return self::parse_video_plays( $response );
364    }
365
366    /**
367     * Collect attachment ID → plays from a `stats/video-plays` response. With `summarize`
368     * the rows sit in `days.summary.plays` ( `data` rows counting `views` with `complete_stats` );
369     * without it each day has its own `plays` rows, which are added up.
370     *
371     * @param mixed $response Decoded response.
372     *
373     * @return array<int, int>
374     */
375    public static function parse_video_plays( $response ) {
376        $response = json_decode( (string) wp_json_encode( $response, JSON_UNESCAPED_SLASHES ), true );
377        if ( ! is_array( $response ) ) {
378            return array();
379        }
380        $days = isset( $response['days'] ) && is_array( $response['days'] ) ? $response['days'] : array();
381        if ( isset( $response['summary'] ) && is_array( $response['summary'] ) ) {
382            $periods = array( $response['summary'] );
383        } elseif ( isset( $days['summary'] ) && is_array( $days['summary'] ) ) {
384            $periods = array( $days['summary'] );
385        } else {
386            $periods = $days;
387        }
388
389        $plays = array();
390        foreach ( $periods as $period ) {
391            if ( ! is_array( $period ) ) {
392                continue;
393            }
394            $rows = $period['plays'] ?? $period['data'] ?? array();
395            foreach ( is_array( $rows ) ? $rows : array() as $row ) {
396                $count = is_array( $row ) ? ( $row['plays'] ?? $row['views'] ?? null ) : null;
397                if ( ! isset( $row['post_id'] ) || ! is_numeric( $row['post_id'] ) || ! is_numeric( $count ) ) {
398                    continue;
399                }
400                $id           = (int) $row['post_id'];
401                $plays[ $id ] = ( $plays[ $id ] ?? 0 ) + max( 0, (int) $count );
402            }
403        }
404
405        return $plays;
406    }
407
408    /**
409     * Add the play count to listing records.
410     *
411     * @param array[] $records Records with an id.
412     *
413     * @return array[]
414     */
415    public static function with_views( array $records ) {
416        $views = self::video_views();
417        foreach ( $records as &$record ) {
418            $record['views'] = $views[ (int) ( $record['id'] ?? 0 ) ] ?? 0;
419        }
420        unset( $record );
421        return $records;
422    }
423
424    /**
425     * The browsing state a request asks for.
426     *
427     * @param bool   $read_filters Whether search, sort and length come from the request ( browse controls shown ).
428     * @param bool   $read_page    Whether the page comes from the request ( numbered pagination ).
429     * @param string $default_sort The block's own order.
430     *
431     * @return array{search: string, sort: string, length: string, page: int}
432     */
433    public static function browse_request( $read_filters, $read_page, $default_sort = 'newest' ) {
434        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- Read-only listing arguments, like core's paged and s.
435        $get   = function ( $key ) {
436            return isset( $_GET[ $key ] ) && is_string( $_GET[ $key ] ) ? sanitize_text_field( wp_unslash( $_GET[ $key ] ) ) : '';
437        };
438        $state = array(
439            'search' => '',
440            'sort'   => in_array( $default_sort, self::BROWSE_SORTS, true ) ? $default_sort : 'newest',
441            'length' => 'any',
442            'page'   => self::DEFAULT_BROWSE_PAGE,
443        );
444
445        if ( $read_filters ) {
446            $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 );
447            $sort            = $get( self::QUERY_ARG_SORT );
448            $sort            = 'latest' === $sort ? 'newest' : $sort;
449            if ( in_array( $sort, self::BROWSE_SORTS, true ) ) {
450                $state['sort'] = $sort;
451            }
452            $length = $get( self::QUERY_ARG_LENGTH );
453            if ( in_array( $length, self::BROWSE_LENGTHS, true ) ) {
454                $state['length'] = $length;
455            }
456        }
457        if ( $read_page ) {
458            $state['page'] = max( 1, absint( $get( self::QUERY_ARG_PAGE ) ) );
459        }
460        // phpcs:enable WordPress.Security.NonceVerification.Recommended
461
462        return $state;
463    }
464
465    /**
466     * One page of the channel's videos for a browsing state.
467     *
468     * @param array    $state       From browse_request().
469     * @param int      $per_page    Page size.
470     * @param string[] $exclude     GUIDs to leave out.
471     * @param bool     $with_views  Whether records need play counts.
472     *
473     * @return array{entries: array[], total: int, page: int, pages: int, start: int}
474     */
475    public static function browse( array $state, $per_page, array $exclude = array(), $with_views = false ) {
476        $result = self::browse_results( $state, $per_page, $exclude, $with_views );
477        $search = trim( (string) ( $state['search'] ?? '' ) );
478        if ( '' !== $search && Transcript_Index::is_search_enabled() ) {
479            foreach ( $result['entries'] as &$entry ) {
480                $match = Transcript_Index::match( (int) $entry['id'], $search );
481                if ( $match ) {
482                    $entry['snippet']      = $match['text'];
483                    $entry['snippetMatch'] = $match['needle'];
484                    $entry['startMs']      = $match['start'];
485                }
486            }
487            unset( $entry );
488        }
489        return $result;
490    }
491
492    /**
493     * One page of browse() before transcript snippets are added.
494     *
495     * @param array    $state      From browse_request().
496     * @param int      $per_page   Page size.
497     * @param string[] $exclude    GUIDs to leave out.
498     * @param bool     $with_views Whether records need play counts.
499     *
500     * @return array{entries: array[], total: int, page: int, pages: int, start: int}
501     */
502    private static function browse_results( array $state, $per_page, array $exclude, $with_views ) {
503        $per_page = max( 1, (int) $per_page );
504        $fast     = self::browse_page( $state, $per_page, $exclude, $with_views );
505        if ( null !== $fast ) {
506            return $fast;
507        }
508        $videos = self::videos( $state['search'] ?? '' );
509
510        if ( $exclude ) {
511            $videos = array_values(
512                array_filter(
513                    $videos,
514                    function ( $video ) use ( $exclude ) {
515                        return ! in_array( $video['guid'], $exclude, true );
516                    }
517                )
518            );
519        }
520
521        $length = $state['length'] ?? 'any';
522        if ( 'any' !== $length ) {
523            $videos = array_values(
524                array_filter(
525                    $videos,
526                    function ( $video ) use ( $length ) {
527                        return self::matches_length( (int) $video['durationMs'], $length );
528                    }
529                )
530            );
531        }
532
533        $sort = $state['sort'] ?? 'newest';
534        if ( $with_views || 'popular' === $sort ) {
535            $videos = self::with_views( $videos );
536        }
537        if ( 'popular' === $sort ) {
538            // Most viewed first; equal counts keep upload order ( newest first ).
539            $position = array_flip( array_keys( $videos ) );
540            uksort(
541                $videos,
542                function ( $a, $b ) use ( $videos, $position ) {
543                    $diff = $videos[ $b ]['views'] - $videos[ $a ]['views'];
544                    return 0 !== $diff ? $diff : $position[ $a ] - $position[ $b ];
545                }
546            );
547            $videos = array_values( $videos );
548        } elseif ( 'oldest' === $sort ) {
549            $videos = array_reverse( $videos );
550        }
551
552        $total = count( $videos );
553        $pages = max( 1, (int) ceil( $total / $per_page ) );
554        $page  = min( $pages, max( 1, (int) ( $state['page'] ?? 1 ) ) );
555        $start = ( $page - 1 ) * $per_page;
556
557        return array(
558            'entries' => array_slice( $videos, $start, $per_page ),
559            'total'   => $total,
560            'page'    => $page,
561            'pages'   => $pages,
562            'start'   => $start,
563        );
564    }
565
566    /**
567     * A browse() page straight from SQL: newest or oldest first, any length, and at
568     * most a few excluded videos on the first page. Null when the full list is
569     * needed ( popular, a length filter, exclusions elsewhere ) or already at hand.
570     *
571     * @param array    $state      From browse_request().
572     * @param int      $per_page   Page size.
573     * @param string[] $exclude    GUIDs to leave out.
574     * @param bool     $with_views Whether records need play counts.
575     *
576     * @return array|null Same shape as browse().
577     */
578    private static function browse_page( array $state, $per_page, array $exclude, $with_views ) {
579        $search = trim( (string) ( $state['search'] ?? '' ) );
580        $sort   = $state['sort'] ?? 'newest';
581        $page   = max( 1, (int) ( $state['page'] ?? 1 ) );
582        if ( ! in_array( $sort, array( 'newest', 'oldest' ), true )
583            || 'any' !== ( $state['length'] ?? 'any' )
584            || ( $exclude && 1 !== $page )
585            || self::has_full_list( $search )
586        ) {
587            return null;
588        }
589
590        $result = self::query_page( $search, 'oldest' === $sort ? 'ASC' : 'DESC', $per_page + count( $exclude ), $page );
591        if ( null === $result ) {
592            return null;
593        }
594
595        $records = $result['records'];
596        $total   = $result['total'];
597        if ( $exclude ) {
598            $kept = array_values(
599                array_filter(
600                    $records,
601                    function ( $video ) use ( $exclude ) {
602                        return ! in_array( $video['guid'], $exclude, true );
603                    }
604                )
605            );
606            // An excluded video beyond this window would make the total wrong.
607            if ( count( $records ) - count( $kept ) !== count( array_unique( $exclude ) ) && count( $records ) < $total ) {
608                return null;
609            }
610            $total  -= count( $records ) - count( $kept );
611            $records = array_slice( $kept, 0, $per_page );
612        }
613
614        $pages = max( 1, (int) ceil( $total / $per_page ) );
615        if ( $page > $pages ) {
616            return null;
617        }
618
619        return array(
620            'entries' => $with_views ? self::with_views( $records ) : $records,
621            'total'   => $total,
622            'page'    => $page,
623            'pages'   => $pages,
624            'start'   => ( $page - 1 ) * $per_page,
625        );
626    }
627
628    /**
629     * Whether a duration falls in a length bucket. Unknown durations match only "any".
630     *
631     * @param int    $duration_ms Duration.
632     * @param string $length      One of BROWSE_LENGTHS.
633     *
634     * @return bool
635     */
636    public static function matches_length( $duration_ms, $length ) {
637        if ( 'any' === $length ) {
638            return true;
639        }
640        if ( $duration_ms <= 0 ) {
641            return false;
642        }
643        switch ( $length ) {
644            case 'short':
645                return $duration_ms < self::SHORT_MAX_MS;
646            case 'mid':
647                return $duration_ms >= self::SHORT_MAX_MS && $duration_ms <= self::LONG_MIN_MS;
648            case 'long':
649                return $duration_ms > self::LONG_MIN_MS;
650        }
651        return true;
652    }
653
654    /**
655     * A video's display details from VideoPress, with the attachment as fallback.
656     *
657     * @param string           $guid       VideoPress GUID.
658     * @param int|WP_Post|null $attachment The attachment, when known.
659     *
660     * @return array{title: string, description: string, poster: string}
661     */
662    public static function video_details( $guid, $attachment = null ) {
663        $details = function_exists( 'videopress_get_video_details' ) && is_string( $guid ) && '' !== $guid ? videopress_get_video_details( $guid ) : null;
664        $details = is_object( $details ) && ! is_wp_error( $details ) ? $details : null;
665        $post    = null !== $attachment ? get_post( $attachment ) : ( $guid ? get_post( Channel::attachment_id( $guid ) ) : null );
666        $post    = $post instanceof WP_Post ? $post : null;
667
668        $title       = $details && ! empty( $details->title ) ? (string) $details->title : ( $post ? get_the_title( $post ) : '' );
669        $description = $details && ! empty( $details->description ) ? (string) $details->description : ( $post ? (string) $post->post_content : '' );
670        $poster      = $details && ! empty( $details->poster ) ? (string) $details->poster : '';
671
672        return array(
673            'title'       => $title,
674            'description' => $description,
675            'poster'      => $poster,
676        );
677    }
678
679    /**
680     * The channel's handle, e.g. "@journeythroughforever".
681     *
682     * @return string
683     */
684    public static function handle() {
685        $host  = (string) wp_parse_url( home_url(), PHP_URL_HOST );
686        $label = $host ? strtok( preg_replace( '/^www\./', '', $host ), '.' ) : '';
687
688        /**
689         * Filters the channel handle shown in the channel details.
690         *
691         * @since 0.58.0
692         *
693         * @param string $handle "@" plus the first label of the site's host; empty for none.
694         */
695        return (string) apply_filters( 'videopress_channel_handle', $label ? self::DEFAULT_HANDLE_PREFIX . $label : '' );
696    }
697
698    /**
699     * Subscriber count, or null when unknown.
700     *
701     * @return int|null
702     */
703    public static function subscriber_count() {
704        $count = function_exists( 'wpcom_subs_total_for_blog' ) ? wpcom_subs_total_for_blog() : self::fetch_subscriber_count();
705
706        /**
707         * Filters the subscriber count shown in the channel details: WordPress.com's count, read
708         * directly on WordPress.com and through the site's connection elsewhere.
709         *
710         * @since 0.58.0
711         *
712         * @param int|null $count Subscribers, or null when unknown.
713         */
714        $count = apply_filters( 'videopress_channel_subscriber_count', is_numeric( $count ) ? (int) $count : null );
715
716        return is_numeric( $count ) ? max( 0, (int) $count ) : null;
717    }
718
719    /**
720     * Subscribers ( email subscribers plus social followers, like the Subscribe block counts them )
721     * from WordPress.com through the site's connection, cached for an hour.
722     *
723     * @return int|null Null when the site is not connected or the request failed.
724     */
725    private static function fetch_subscriber_count() {
726        $cached = get_transient( self::SUBSCRIBERS_TRANSIENT );
727        if ( is_array( $cached ) && array_key_exists( 'count', $cached ) ) {
728            return is_numeric( $cached['count'] ) ? (int) $cached['count'] : null;
729        }
730
731        $blog_id = (int) VideoPressToken::blog_id();
732        $count   = $blog_id
733            ? self::parse_subscriber_counts(
734                Client::wpcom_json_api_request_as_blog( sprintf( '/sites/%d/subscribers/counts', $blog_id ), '2', array( 'timeout' => 5 ), null, 'wpcom' )
735            )
736            : null;
737
738        set_transient( self::SUBSCRIBERS_TRANSIENT, array( 'count' => $count ), null === $count ? self::SUBSCRIBERS_ERROR_TTL : self::SUBSCRIBERS_TTL );
739        return $count;
740    }
741
742    /**
743     * The subscriber total in a `/sites/%d/subscribers/counts` response:
744     * `{ "counts": { "total_subscribers": 42, "social_followers": 9, … } }`.
745     *
746     * @param mixed $response HTTP response array or WP_Error.
747     *
748     * @return int|null
749     */
750    public static function parse_subscriber_counts( $response ) {
751        if ( is_wp_error( $response ) || ! is_array( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
752            return null;
753        }
754        $body   = json_decode( (string) wp_remote_retrieve_body( $response ), true );
755        $counts = is_array( $body ) && isset( $body['counts'] ) && is_array( $body['counts'] ) ? $body['counts'] : null;
756        if ( ! $counts || ! isset( $counts['total_subscribers'] ) || ! is_numeric( $counts['total_subscribers'] ) ) {
757            return null;
758        }
759        $followers = isset( $counts['social_followers'] ) && is_numeric( $counts['social_followers'] ) ? (int) $counts['social_followers'] : 0;
760        return max( 0, (int) $counts['total_subscribers'] + $followers );
761    }
762
763    /**
764     * Total plays of the channel's videos.
765     *
766     * @return int
767     */
768    public static function total_views() {
769        $ids   = array_flip( array_column( self::videos(), 'id' ) );
770        $total = 0;
771        foreach ( self::video_views() as $id => $count ) {
772            if ( isset( $ids[ $id ] ) ) {
773                $total += $count;
774            }
775        }
776        return $total;
777    }
778
779    /**
780     * When the channel started: the site's registration date on a network,
781     * else its oldest published post. Unix time, 0 when unknown.
782     *
783     * @return int
784     */
785    public static function joined() {
786        $time = 0;
787        if ( is_multisite() && function_exists( 'get_site' ) ) {
788            $site = get_site();
789            if ( $site && ! empty( $site->registered ) && '0000-00-00 00:00:00' !== $site->registered ) {
790                $time = (int) strtotime( $site->registered . ' UTC' );
791            }
792        }
793        if ( ! $time ) {
794            $oldest = get_posts(
795                array(
796                    'post_type'      => 'any',
797                    'post_status'    => 'publish',
798                    'orderby'        => 'date',
799                    'order'          => 'ASC',
800                    'posts_per_page' => 1,
801                )
802            );
803            if ( $oldest && $oldest[0] instanceof WP_Post ) {
804                $time = (int) strtotime( $oldest[0]->post_date_gmt . ' UTC' );
805            }
806        }
807
808        /**
809         * Filters when the channel started, shown as "Joined".
810         *
811         * @since 0.58.0
812         *
813         * @param int $time Unix time, 0 when unknown.
814         */
815        return max( 0, (int) apply_filters( 'videopress_channel_joined', $time ) );
816    }
817
818    /**
819     * A compact count: 950, 1.2K, 48K, 1.2M.
820     *
821     * @param int $count The number.
822     *
823     * @return string
824     */
825    public static function compact_number( $count ) {
826        $count = max( 0, (int) $count );
827        if ( $count >= self::COMPACT_MILLION ) {
828            /* translators: %s: a number of millions, e.g. 1.2 */
829            return sprintf( __( '%sM', 'jetpack-videopress-pkg' ), number_format_i18n( $count / self::COMPACT_MILLION, 1 ) );
830        }
831        if ( $count >= self::COMPACT_TEN_THOUSAND ) {
832            /* translators: %s: a number of thousands, e.g. 48 */
833            return sprintf( __( '%sK', 'jetpack-videopress-pkg' ), number_format_i18n( floor( $count / self::COMPACT_THOUSAND ) ) );
834        }
835        if ( $count >= self::COMPACT_THOUSAND ) {
836            /* translators: %s: a number of thousands, e.g. 1.2 */
837            return sprintf( __( '%sK', 'jetpack-videopress-pkg' ), number_format_i18n( $count / self::COMPACT_THOUSAND, 1 ) );
838        }
839        return number_format_i18n( $count );
840    }
841
842    /**
843     * "3 days ago" for a unix time.
844     *
845     * @param int $time Unix time.
846     *
847     * @return string Empty when unknown.
848     */
849    public static function ago( $time ) {
850        $time = (int) $time;
851        if ( $time <= 0 ) {
852            return '';
853        }
854        /* translators: %s: a time span, e.g. "3 days". */
855        return sprintf( __( '%s ago', 'jetpack-videopress-pkg' ), human_time_diff( $time, time() ) );
856    }
857
858    /**
859     * "12K views · 3 days ago", from a listing record.
860     *
861     * @param array $record     Record with views and publishedAt.
862     * @param bool  $full_count Whether to print the full view count ( 12,345 ) instead of the compact one.
863     *
864     * @return string
865     */
866    public static function views_and_age( array $record, $full_count = false ) {
867        $parts = array();
868        if ( isset( $record['views'] ) ) {
869            $views   = (int) $record['views'];
870            $number  = $full_count ? number_format_i18n( $views ) : self::compact_number( $views );
871            $parts[] = sprintf(
872                /* translators: %s: number of views, e.g. "12K". */
873                _n( '%s view', '%s views', $views, 'jetpack-videopress-pkg' ),
874                $number
875            );
876        }
877        $ago = self::ago( (int) ( $record['publishedAt'] ?? 0 ) );
878        if ( '' !== $ago ) {
879            $parts[] = $ago;
880        }
881        return implode( ' · ', $parts );
882    }
883}