Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.70% covered (success)
97.70%
212 / 217
86.67% covered (warning)
86.67%
26 / 30
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel
97.70% covered (success)
97.70%
212 / 217
86.67% covered (warning)
86.67%
26 / 30
141
0.00% covered (danger)
0.00%
0 / 1
 init
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
2.06
 is_enabled
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 register
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
2.00
 guid
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
11
 attachment_id
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
13
 is_attachment_for
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 video_url
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 playlist_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 endpoint_url
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_site_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 query_vars
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 route_video_page
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
14
 template_hierarchy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 playlist_template_hierarchy
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 attachment_link
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 playlist_entry_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 current_video
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 queried_video_block
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
17.19
 queried_start
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
4
 is_video_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_playlist_page
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 queried_guid
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 playlist_record
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 playlists
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
7
 normalize_record
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 queried_playlist
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
5
 source_playlist
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
6
 playlists_with_video
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 entries_with_meta
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
4
 playlist_source_block
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
11
1<?php
2/**
3 * VideoPress Channel: a page for every video in the media library.
4 *
5 * A video is a VideoPress attachment; nothing has to be written around it.
6 * `/videopress?v=<guid>` renders the theme's `videopress-video` block template
7 * for that attachment, where a video block with `useQueriedVideo` plays it.
8 * Titles, posters and the rest of the video's metadata come from VideoPress
9 * at render time and are never stored on the blog.
10 *
11 * Enabled when the active theme declares `add_theme_support( 'videopress-channel' )`
12 * or the `videopress_channel_enabled` filter returns true.
13 *
14 * @package automattic/jetpack-videopress
15 */
16
17namespace Automattic\Jetpack\VideoPress;
18
19use WP;
20use WP_Post;
21
22/**
23 * VideoPress Channel feature.
24 */
25class Channel {
26
27    const MIME_TYPE          = 'video/videopress';
28    const ENDPOINT           = 'videopress';
29    const QUERY_VAR          = 'videopress_video';
30    const TEMPLATE           = 'videopress-video';
31    const PLAYLIST_QUERY_VAR = 'videopress_playlist';
32    const LIST_VAR           = 'list';
33    const START_VAR          = 't';
34    const PLAYLIST_TEMPLATE  = 'videopress-playlist';
35    const PLAYLIST_SOURCES   = array( 'own', 'queried', 'index' );
36
37    /**
38     * Whether the feature has been wired up in this request.
39     *
40     * @var bool
41     */
42    private static $initialized = false;
43
44    /**
45     * Hook the feature up. Registration itself waits for `init`, when the
46     * active theme is known.
47     *
48     * @return void
49     */
50    public static function init() {
51        if ( self::$initialized ) {
52            return;
53        }
54        self::$initialized = true;
55
56        add_action( 'init', array( __CLASS__, 'register' ), 9 );
57    }
58
59    /**
60     * Whether the channel feature is enabled for this site.
61     *
62     * @return bool
63     */
64    public static function is_enabled() {
65        $enabled = function_exists( 'current_theme_supports' ) && current_theme_supports( 'videopress-channel' );
66
67        /**
68         * Filters whether the VideoPress Channel feature ( video pages ) is enabled.
69         *
70         * @since 0.55.0
71         *
72         * @param bool $enabled True when the active theme supports `videopress-channel`.
73         */
74        return (bool) apply_filters( 'videopress_channel_enabled', $enabled );
75    }
76
77    /**
78     * Register the video page routing and the block hooks.
79     *
80     * @return void
81     */
82    public static function register() {
83        if ( ! self::is_enabled() ) {
84            return;
85        }
86
87        add_filter( 'query_vars', array( __CLASS__, 'query_vars' ) );
88        add_action( 'parse_request', array( __CLASS__, 'route_video_page' ) );
89        add_filter( 'attachment_template_hierarchy', array( __CLASS__, 'template_hierarchy' ) );
90        add_filter( 'attachment_link', array( __CLASS__, 'attachment_link' ), 10, 2 );
91        add_filter( 'videopress_playlist_entry_url', array( __CLASS__, 'playlist_entry_url' ), 10, 2 );
92        add_filter( 'render_block_data', array( __CLASS__, 'queried_video_block' ) );
93        add_filter( 'single_template_hierarchy', array( __CLASS__, 'playlist_template_hierarchy' ) );
94        add_filter( 'page_template_hierarchy', array( __CLASS__, 'playlist_template_hierarchy' ) );
95    }
96
97    /**
98     * The VideoPress GUID of an attachment, or an empty string.
99     *
100     * Jetpack sites store it in attachment meta; WordPress.com Simple keeps the
101     * uploaded file's attachment ( still `video/mp4` ) and maps it in the
102     * platform's videos table.
103     *
104     * @param int|WP_Post $attachment The attachment.
105     * @return string
106     */
107    public static function guid( $attachment ) {
108        $post = get_post( $attachment );
109        if ( ! $post instanceof WP_Post || 'attachment' !== $post->post_type || 0 !== strpos( (string) $post->post_mime_type, 'video/' ) ) {
110            return '';
111        }
112        $guid = (string) get_post_meta( $post->ID, 'videopress_guid', true );
113        if ( '' === $guid ) {
114            $meta = wp_get_attachment_metadata( $post->ID );
115            $guid = isset( $meta['videopress']['guid'] ) ? (string) $meta['videopress']['guid'] : '';
116        }
117        if ( '' === $guid && function_exists( 'video_get_info_by_blogpostid' ) ) {
118            // WordPress.com's videos table; the package shims this from post meta elsewhere.
119            $info = video_get_info_by_blogpostid( get_current_blog_id(), $post->ID );
120            $guid = is_object( $info ) && ! empty( $info->guid ) ? (string) $info->guid : '';
121        }
122        return preg_match( '/^[a-zA-Z0-9]{8}$/', $guid ) ? $guid : '';
123    }
124
125    /**
126     * The attachment a VideoPress GUID belongs to on this site, or 0.
127     *
128     * @param string $guid The GUID.
129     * @return int
130     */
131    public static function attachment_id( $guid ) {
132        if ( ! is_string( $guid ) || ! preg_match( '/^[a-zA-Z0-9]{8}$/', $guid ) ) {
133            return 0;
134        }
135
136        /*
137         * WordPress.com's videopress_get_post_id_by_guid() returns the post ID of
138         * whichever blog owns the GUID, so every candidate must prove it is this
139         * blog's attachment for that GUID before it is used.
140         */
141        $candidates = array();
142        if ( function_exists( 'videopress_get_post_id_by_guid' ) ) {
143            $candidates[] = videopress_get_post_id_by_guid( $guid );
144        }
145        if ( function_exists( 'video_get_info_by_guid' ) ) {
146            $info = video_get_info_by_guid( $guid );
147            if ( is_object( $info ) && ! empty( $info->post_id ) && isset( $info->blog_id ) && (int) $info->blog_id === get_current_blog_id() ) {
148                $candidates[] = $info->post_id;
149            }
150        }
151
152        foreach ( $candidates as $candidate ) {
153            $id = is_numeric( $candidate ) ? (int) $candidate : 0;
154            if ( $id > 0 && self::is_attachment_for( $id, $guid ) ) {
155                return $id;
156            }
157        }
158        return 0;
159    }
160
161    /**
162     * Whether a post ID is this blog's VideoPress attachment for a GUID.
163     *
164     * @param int    $id   Post ID.
165     * @param string $guid The GUID.
166     * @return bool
167     */
168    private static function is_attachment_for( $id, $guid ) {
169        $post = get_post( $id );
170        return $post instanceof WP_Post && 'attachment' === $post->post_type && self::guid( $post ) === $guid;
171    }
172
173    /**
174     * The channel page of a video: `/videopress?v=<guid>`, plus `&list=<key>` in a playlist.
175     *
176     * @param int|WP_Post $attachment The attachment.
177     * @param string      $list_key   Playlist the video is played from, if any.
178     * @return string Empty when the attachment is not a VideoPress video.
179     */
180    public static function video_url( $attachment, $list_key = '' ) {
181        $guid = self::guid( $attachment );
182        if ( '' === $guid ) {
183            return '';
184        }
185        $args = array( 'v' => $guid );
186        if ( is_string( $list_key ) && '' !== $list_key ) {
187            $args[ self::LIST_VAR ] = $list_key;
188        }
189        return add_query_arg( $args, self::endpoint_url() );
190    }
191
192    /**
193     * The page of a playlist: `/videopress?list=<key>`.
194     *
195     * @param string $key Playlist key.
196     * @return string
197     */
198    public static function playlist_url( $key ) {
199        return add_query_arg( self::LIST_VAR, rawurlencode( (string) $key ), self::endpoint_url() );
200    }
201
202    /**
203     * `/videopress/` on this site.
204     *
205     * @return string
206     */
207    private static function endpoint_url() {
208        return user_trailingslashit( home_url( '/' . self::ENDPOINT ) );
209    }
210
211    /**
212     * Whether a URL points at this site rather than, say, videopress.com.
213     *
214     * @param string $url The URL.
215     * @return bool
216     */
217    public static function is_site_url( $url ) {
218        $host = wp_parse_url( (string) $url, PHP_URL_HOST );
219        return is_string( $host ) && strtolower( $host ) === strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
220    }
221
222    /**
223     * Public query vars: `v` selects the video, `list` the playlist, the others mark the page.
224     *
225     * @param string[] $vars Query vars.
226     * @return string[]
227     */
228    public static function query_vars( $vars ) {
229        $vars[] = 'v';
230        $vars[] = self::QUERY_VAR;
231        $vars[] = self::LIST_VAR;
232        $vars[] = self::PLAYLIST_QUERY_VAR;
233        return $vars;
234    }
235
236    /**
237     * `/videopress?v=<guid>` becomes the attachment's request, so the main
238     * query, the template context and the post blocks all see the video;
239     * `/videopress?list=<key>` becomes the request of the post holding the
240     * playlist. Unknown GUIDs and keys 404.
241     *
242     * @param WP $wp The request.
243     * @return void
244     */
245    public static function route_video_page( $wp ) {
246        $path = trim( (string) $wp->request, '/' );
247        if ( self::ENDPOINT !== $path && empty( $wp->query_vars[ self::QUERY_VAR ] ) && empty( $wp->query_vars[ self::PLAYLIST_QUERY_VAR ] ) ) {
248            return;
249        }
250        // phpcs:disable WordPress.Security.NonceVerification.Recommended -- public URL.
251        $guid = isset( $_GET['v'] ) ? sanitize_text_field( wp_unslash( $_GET['v'] ) ) : ( $wp->query_vars['v'] ?? '' );
252        $key  = isset( $_GET[ self::LIST_VAR ] ) ? sanitize_key( wp_unslash( $_GET[ self::LIST_VAR ] ) ) : sanitize_key( (string) ( $wp->query_vars[ self::LIST_VAR ] ?? '' ) );
253        // phpcs:enable WordPress.Security.NonceVerification.Recommended
254
255        if ( '' === $guid && '' !== $key ) {
256            $record = self::playlist_record( $key );
257            $post   = $record ? get_post( $record['post_id'] ) : null;
258            if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) {
259                $wp->query_vars = array( 'error' => '404' );
260                return;
261            }
262            $wp->query_vars = array(
263                'p'                      => $post->ID,
264                'post_type'              => $post->post_type,
265                self::PLAYLIST_QUERY_VAR => 1,
266                self::LIST_VAR           => $key,
267            );
268            return;
269        }
270
271        $id = self::attachment_id( $guid );
272        if ( ! $id ) {
273            $wp->query_vars = array( 'error' => '404' );
274            return;
275        }
276        $wp->query_vars = array(
277            'attachment_id' => $id,
278            'post_type'     => 'attachment',
279            self::QUERY_VAR => 1,
280            'v'             => $guid,
281        );
282        if ( '' !== $key && self::playlist_record( $key ) ) {
283            $wp->query_vars[ self::LIST_VAR ] = $key;
284        }
285    }
286
287    /**
288     * Video pages render the theme's `videopress-video` block template.
289     *
290     * @param string[] $templates Template hierarchy.
291     * @return string[]
292     */
293    public static function template_hierarchy( $templates ) {
294        if ( get_query_var( self::QUERY_VAR ) ) {
295            array_unshift( $templates, self::TEMPLATE . '.php' );
296        }
297        return $templates;
298    }
299
300    /**
301     * Playlist pages render the theme's `videopress-playlist` block template.
302     *
303     * @param string[] $templates Template hierarchy.
304     * @return string[]
305     */
306    public static function playlist_template_hierarchy( $templates ) {
307        if ( get_query_var( self::PLAYLIST_QUERY_VAR ) ) {
308            array_unshift( $templates, self::PLAYLIST_TEMPLATE . '.php' );
309        }
310        return $templates;
311    }
312
313    /**
314     * VideoPress attachments link to their channel page.
315     *
316     * @param string $link    Attachment permalink.
317     * @param int    $post_id The attachment.
318     * @return string
319     */
320    public static function attachment_link( $link, $post_id ) {
321        $url = self::video_url( $post_id );
322        return '' === $url ? $link : $url;
323    }
324
325    /**
326     * Playlist entries that open a video link to its channel page when the
327     * video belongs to this site.
328     *
329     * @param string $url  The entry URL ( videopress.com by default ).
330     * @param string $guid The video GUID.
331     * @return string
332     */
333    public static function playlist_entry_url( $url, $guid ) {
334        $id = self::attachment_id( $guid );
335        return $id ? self::video_url( $id ) : $url;
336    }
337
338    /**
339     * The video a block refers to: the Query Loop entry when it is a
340     * VideoPress attachment, else the queried attachment.
341     *
342     * @param int $context_post_id The block's postId context, if any.
343     * @return WP_Post|null
344     */
345    public static function current_video( $context_post_id = 0 ) {
346        $candidates = array( (int) $context_post_id, (int) get_the_ID() );
347        $queried    = get_queried_object();
348        if ( $queried instanceof WP_Post ) {
349            $candidates[] = (int) $queried->ID;
350        }
351        foreach ( $candidates as $id ) {
352            $post = $id ? get_post( $id ) : null;
353            if ( $post instanceof WP_Post && '' !== self::guid( $post ) ) {
354                return $post;
355            }
356        }
357        return null;
358    }
359
360    /**
361     * A video block with `useQueriedVideo` plays the current video, and one
362     * with `useLatestVideo` the newest upload. Its title and poster are read
363     * from VideoPress at render time, not from the blog.
364     *
365     * @param array $parsed_block The block being rendered.
366     * @return array
367     */
368    public static function queried_video_block( $parsed_block ) {
369        if ( ! isset( $parsed_block['blockName'] ) || 'videopress/video' !== $parsed_block['blockName'] ) {
370            return $parsed_block;
371        }
372        if ( ! empty( $parsed_block['attrs']['useQueriedVideo'] ) ) {
373            $post = self::current_video();
374        } elseif ( ! empty( $parsed_block['attrs']['useLatestVideo'] ) ) {
375            $post = Channel_Data::latest_video();
376        } else {
377            return $parsed_block;
378        }
379        $guid = $post instanceof WP_Post ? self::guid( $post ) : '';
380        if ( ! $post instanceof WP_Post || '' === $guid ) {
381            $parsed_block['attrs']['guid'] = '';
382            return $parsed_block;
383        }
384        $details = function_exists( 'videopress_get_video_details' ) ? videopress_get_video_details( $guid ) : null;
385        $details = is_object( $details ) && ! is_wp_error( $details ) ? $details : null;
386
387        $parsed_block['attrs']['guid']  = $guid;
388        $parsed_block['attrs']['id']    = $post->ID;
389        $parsed_block['attrs']['src']   = 'https://videopress.com/v/' . $guid;
390        $parsed_block['attrs']['title'] = $details && ! empty( $details->title ) ? (string) $details->title : get_the_title( $post );
391        $start                          = ! empty( $parsed_block['attrs']['useQueriedVideo'] ) ? self::queried_start() : 0;
392        if ( $start > 0 ) {
393            $parsed_block['attrs']['at'] = $start;
394        }
395        if ( $details && ! empty( $details->poster ) ) {
396            $parsed_block['attrs']['poster'] = (string) $details->poster;
397        }
398        return $parsed_block;
399    }
400
401    /**
402     * The second a video page asks playback to start at ( `&t=72` ), or 0.
403     *
404     * @return int
405     */
406    public static function queried_start() {
407        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only playback position, like a share link.
408        return self::is_video_page() && isset( $_GET[ self::START_VAR ] ) && is_string( $_GET[ self::START_VAR ] ) ? absint( $_GET[ self::START_VAR ] ) : 0;
409    }
410
411    /**
412     * Whether this request is a channel video page.
413     *
414     * @return bool
415     */
416    public static function is_video_page() {
417        return (bool) get_query_var( self::QUERY_VAR );
418    }
419
420    /**
421     * Whether this request is a channel playlist page.
422     *
423     * @return bool
424     */
425    public static function is_playlist_page() {
426        return (bool) get_query_var( self::PLAYLIST_QUERY_VAR );
427    }
428
429    /**
430     * The GUID of the video page's video, or an empty string.
431     *
432     * @return string
433     */
434    public static function queried_guid() {
435        if ( ! self::is_video_page() ) {
436            return '';
437        }
438        $post = self::current_video();
439        return $post instanceof WP_Post ? self::guid( $post ) : '';
440    }
441
442    /**
443     * One indexed playlist with its key, or null.
444     *
445     * @param string $key Playlist key.
446     * @return array|null Record with key, title, description, videos and post_id.
447     */
448    public static function playlist_record( $key ) {
449        $key = is_string( $key ) ? sanitize_key( $key ) : '';
450        if ( '' === $key ) {
451            return null;
452        }
453        $index = Playlist_Index::get_playlists();
454        if ( ! isset( $index[ $key ] ) || ! is_array( $index[ $key ] ) ) {
455            return null;
456        }
457        return self::normalize_record( $key, $index[ $key ] );
458    }
459
460    /**
461     * Every indexed playlist whose source post is published, most recently updated first.
462     *
463     * @return array[] Records as playlist_record() returns them, plus `updated` ( unix time ).
464     */
465    public static function playlists() {
466        $playlists = array();
467        foreach ( Playlist_Index::get_playlists() as $key => $record ) {
468            if ( ! is_array( $record ) ) {
469                continue;
470            }
471            $record = self::normalize_record( (string) $key, $record );
472            $post   = $record['post_id'] ? get_post( $record['post_id'] ) : null;
473            if ( ! $post instanceof WP_Post || 'publish' !== $post->post_status ) {
474                continue;
475            }
476            $record['updated'] = (int) strtotime( $post->post_modified_gmt . ' UTC' );
477            $playlists[]       = $record;
478        }
479        usort(
480            $playlists,
481            function ( $a, $b ) {
482                return $b['updated'] !== $a['updated'] ? $b['updated'] - $a['updated'] : strcmp( $a['key'], $b['key'] );
483            }
484        );
485        return $playlists;
486    }
487
488    /**
489     * Normalize an index record.
490     *
491     * @param string $key    Playlist key.
492     * @param array  $record Index record.
493     * @return array
494     */
495    private static function normalize_record( $key, array $record ) {
496        return array(
497            'key'         => $key,
498            'title'       => isset( $record['title'] ) && is_string( $record['title'] ) ? $record['title'] : '',
499            'description' => isset( $record['description'] ) && is_string( $record['description'] ) ? $record['description'] : '',
500            'videos'      => Initializer::sanitize_playlist_entries( $record['videos'] ?? null ),
501            'post_id'     => isset( $record['post_id'] ) ? absint( $record['post_id'] ) : 0,
502        );
503    }
504
505    /**
506     * The playlist the URL names on a video or playlist page, or null.
507     *
508     * @return array|null Record as playlist_record() returns it.
509     */
510    public static function queried_playlist() {
511        if ( ! self::is_video_page() && ! self::is_playlist_page() ) {
512            return null;
513        }
514        $key = get_query_var( self::LIST_VAR );
515        return is_string( $key ) && '' !== $key ? self::playlist_record( $key ) : null;
516    }
517
518    /**
519     * The playlist a block shows: the queried one, or an indexed one by key
520     * ( the most recently updated when no key is set ).
521     *
522     * @param string $source One of PLAYLIST_SOURCES.
523     * @param string $key    Playlist key for the `index` source.
524     * @return array|null
525     */
526    public static function source_playlist( $source, $key = '' ) {
527        if ( 'queried' === $source ) {
528            return self::queried_playlist();
529        }
530        if ( 'index' !== $source ) {
531            return null;
532        }
533        if ( is_string( $key ) && '' !== $key ) {
534            return self::playlist_record( $key );
535        }
536        $playlists = self::playlists();
537        return $playlists ? $playlists[0] : null;
538    }
539
540    /**
541     * The published playlists that hold a video.
542     *
543     * @param string $guid The video GUID.
544     * @return array[]
545     */
546    public static function playlists_with_video( $guid ) {
547        return array_values(
548            array_filter(
549                self::playlists(),
550                function ( $playlist ) use ( $guid ) {
551                    return in_array( $guid, array_column( $playlist['videos'], 'guid' ), true );
552                }
553            )
554        );
555    }
556
557    /**
558     * Add views and upload time to playlist entries, for their meta line.
559     *
560     * @param array[] $entries Sanitized playlist entries.
561     * @return array[]
562     */
563    public static function entries_with_meta( array $entries ) {
564        $views = Channel_Data::video_views();
565        foreach ( $entries as &$entry ) {
566            $id    = self::attachment_id( $entry['guid'] );
567            $post  = $id ? get_post( $id ) : null;
568            $entry = array_merge(
569                $entry,
570                array(
571                    'views'       => $views[ $id ] ?? 0,
572                    'publishedAt' => $post instanceof WP_Post ? (int) strtotime( $post->post_date_gmt . ' UTC' ) : 0,
573                )
574            );
575        }
576        unset( $entry );
577        return $entries;
578    }
579
580    /**
581     * A Video Playlist block whose `source` is `queried` or `index` shows that
582     * playlist; its entries then open their video pages within the playlist.
583     * Hooked by Channel_Blocks::register() on every site with the block, channel
584     * theme or not.
585     *
586     * @param array $parsed_block The block being rendered.
587     * @return array
588     */
589    public static function playlist_source_block( $parsed_block ) {
590        if ( ! isset( $parsed_block['blockName'] ) || Playlist_Index::BLOCK_NAME !== $parsed_block['blockName'] ) {
591            return $parsed_block;
592        }
593        $attrs  = isset( $parsed_block['attrs'] ) && is_array( $parsed_block['attrs'] ) ? $parsed_block['attrs'] : array();
594        $source = isset( $attrs['source'] ) && in_array( $attrs['source'], self::PLAYLIST_SOURCES, true ) ? $attrs['source'] : 'own';
595        if ( 'own' === $source ) {
596            return $parsed_block;
597        }
598
599        $playlist = self::source_playlist( $source, isset( $attrs['sourcePlaylistId'] ) ? (string) $attrs['sourcePlaylistId'] : '' );
600        if ( ! $playlist ) {
601            $parsed_block['attrs']['videos'] = array();
602            return $parsed_block;
603        }
604
605        $parsed_block['attrs']['videos']              = ! empty( $attrs['showMeta'] ) ? self::entries_with_meta( $playlist['videos'] ) : $playlist['videos'];
606        $parsed_block['attrs']['playlistTitle']       = $playlist['title'];
607        $parsed_block['attrs']['playlistDescription'] = $playlist['description'];
608        $parsed_block['attrs']['videopressListKey']   = $playlist['key'];
609        return $parsed_block;
610    }
611}