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