Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.96% covered (success)
95.96%
95 / 99
76.47% covered (warning)
76.47%
13 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel
95.96% covered (success)
95.96%
95 / 99
76.47% covered (warning)
76.47%
13 / 17
73
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
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
2.01
 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%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 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%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 route_video_page
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
5
 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
 queried_guid
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
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
32    /**
33     * Whether the feature has been wired up in this request.
34     *
35     * @var bool
36     */
37    private static $initialized = false;
38
39    /**
40     * Hook the feature up. Registration itself waits for `init`, when the
41     * active theme is known.
42     *
43     * @return void
44     */
45    public static function init() {
46        if ( self::$initialized ) {
47            return;
48        }
49        self::$initialized = true;
50
51        add_action( 'init', array( __CLASS__, 'register' ), 9 );
52    }
53
54    /**
55     * Whether the channel feature is enabled for this site.
56     *
57     * @return bool
58     */
59    public static function is_enabled() {
60        $enabled = function_exists( 'current_theme_supports' ) && current_theme_supports( 'videopress-channel' );
61
62        /**
63         * Filters whether the VideoPress Channel feature ( video pages ) is enabled.
64         *
65         * @since 0.55.0
66         *
67         * @param bool $enabled True when the active theme supports `videopress-channel`.
68         */
69        return (bool) apply_filters( 'videopress_channel_enabled', $enabled );
70    }
71
72    /**
73     * Register the video page routing and the block hooks.
74     *
75     * @return void
76     */
77    public static function register() {
78        if ( ! self::is_enabled() ) {
79            return;
80        }
81
82        add_filter( 'query_vars', array( __CLASS__, 'query_vars' ) );
83        add_action( 'parse_request', array( __CLASS__, 'route_video_page' ) );
84        add_filter( 'attachment_template_hierarchy', array( __CLASS__, 'template_hierarchy' ) );
85        add_filter( 'attachment_link', array( __CLASS__, 'attachment_link' ), 10, 2 );
86        add_filter( 'videopress_playlist_entry_url', array( __CLASS__, 'playlist_entry_url' ), 10, 2 );
87        add_filter( 'render_block_data', array( __CLASS__, 'queried_video_block' ) );
88    }
89
90    /**
91     * The VideoPress GUID of an attachment, or an empty string.
92     *
93     * Jetpack sites store it in attachment meta; WordPress.com Simple keeps the
94     * uploaded file's attachment ( still `video/mp4` ) and maps it in the
95     * platform's videos table.
96     *
97     * @param int|WP_Post $attachment The attachment.
98     * @return string
99     */
100    public static function guid( $attachment ) {
101        $post = get_post( $attachment );
102        if ( ! $post instanceof WP_Post || 'attachment' !== $post->post_type || 0 !== strpos( (string) $post->post_mime_type, 'video/' ) ) {
103            return '';
104        }
105        $guid = (string) get_post_meta( $post->ID, 'videopress_guid', true );
106        if ( '' === $guid ) {
107            $meta = wp_get_attachment_metadata( $post->ID );
108            $guid = isset( $meta['videopress']['guid'] ) ? (string) $meta['videopress']['guid'] : '';
109        }
110        if ( '' === $guid && function_exists( 'video_get_info_by_blogpostid' ) ) {
111            // WordPress.com's videos table; the package shims this from post meta elsewhere.
112            $info = video_get_info_by_blogpostid( get_current_blog_id(), $post->ID );
113            $guid = is_object( $info ) && ! empty( $info->guid ) ? (string) $info->guid : '';
114        }
115        return preg_match( '/^[a-zA-Z0-9]{8}$/', $guid ) ? $guid : '';
116    }
117
118    /**
119     * The attachment a VideoPress GUID belongs to on this site, or 0.
120     *
121     * @param string $guid The GUID.
122     * @return int
123     */
124    public static function attachment_id( $guid ) {
125        if ( ! is_string( $guid ) || ! preg_match( '/^[a-zA-Z0-9]{8}$/', $guid ) ) {
126            return 0;
127        }
128
129        /*
130         * WordPress.com's videopress_get_post_id_by_guid() returns the post ID of
131         * whichever blog owns the GUID, so every candidate must prove it is this
132         * blog's attachment for that GUID before it is used.
133         */
134        $candidates = array();
135        if ( function_exists( 'videopress_get_post_id_by_guid' ) ) {
136            $candidates[] = videopress_get_post_id_by_guid( $guid );
137        }
138        if ( function_exists( 'video_get_info_by_guid' ) ) {
139            $info = video_get_info_by_guid( $guid );
140            if ( is_object( $info ) && ! empty( $info->post_id ) && isset( $info->blog_id ) && (int) $info->blog_id === get_current_blog_id() ) {
141                $candidates[] = $info->post_id;
142            }
143        }
144
145        foreach ( $candidates as $candidate ) {
146            $id = is_numeric( $candidate ) ? (int) $candidate : 0;
147            if ( $id > 0 && self::is_attachment_for( $id, $guid ) ) {
148                return $id;
149            }
150        }
151        return 0;
152    }
153
154    /**
155     * Whether a post ID is this blog's VideoPress attachment for a GUID.
156     *
157     * @param int    $id   Post ID.
158     * @param string $guid The GUID.
159     * @return bool
160     */
161    private static function is_attachment_for( $id, $guid ) {
162        $post = get_post( $id );
163        return $post instanceof WP_Post && 'attachment' === $post->post_type && self::guid( $post ) === $guid;
164    }
165
166    /**
167     * The channel page of a video: `/videopress?v=<guid>`.
168     *
169     * @param int|WP_Post $attachment The attachment.
170     * @return string Empty when the attachment is not a VideoPress video.
171     */
172    public static function video_url( $attachment ) {
173        $guid = self::guid( $attachment );
174        return '' === $guid ? '' : add_query_arg( 'v', $guid, user_trailingslashit( home_url( '/' . self::ENDPOINT ) ) );
175    }
176
177    /**
178     * Whether a URL points at this site rather than, say, videopress.com.
179     *
180     * @param string $url The URL.
181     * @return bool
182     */
183    public static function is_site_url( $url ) {
184        $host = wp_parse_url( (string) $url, PHP_URL_HOST );
185        return is_string( $host ) && strtolower( $host ) === strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
186    }
187
188    /**
189     * Public query vars: `v` selects the video, `videopress_video` marks the page.
190     *
191     * @param string[] $vars Query vars.
192     * @return string[]
193     */
194    public static function query_vars( $vars ) {
195        $vars[] = 'v';
196        $vars[] = self::QUERY_VAR;
197        return $vars;
198    }
199
200    /**
201     * `/videopress?v=<guid>` becomes the attachment's request, so the main
202     * query, the template context and the post blocks all see the video.
203     * An unknown GUID 404s.
204     *
205     * @param WP $wp The request.
206     * @return void
207     */
208    public static function route_video_page( $wp ) {
209        $path = trim( (string) $wp->request, '/' );
210        if ( self::ENDPOINT !== $path && empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
211            return;
212        }
213        $guid = isset( $_GET['v'] ) ? sanitize_text_field( wp_unslash( $_GET['v'] ) ) : ( $wp->query_vars['v'] ?? '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public URL.
214        $id   = self::attachment_id( $guid );
215        if ( ! $id ) {
216            $wp->query_vars = array( 'error' => '404' );
217            return;
218        }
219        $wp->query_vars = array(
220            'attachment_id' => $id,
221            'post_type'     => 'attachment',
222            self::QUERY_VAR => 1,
223            'v'             => $guid,
224        );
225    }
226
227    /**
228     * Video pages render the theme's `videopress-video` block template.
229     *
230     * @param string[] $templates Template hierarchy.
231     * @return string[]
232     */
233    public static function template_hierarchy( $templates ) {
234        if ( get_query_var( self::QUERY_VAR ) ) {
235            array_unshift( $templates, self::TEMPLATE . '.php' );
236        }
237        return $templates;
238    }
239
240    /**
241     * VideoPress attachments link to their channel page.
242     *
243     * @param string $link    Attachment permalink.
244     * @param int    $post_id The attachment.
245     * @return string
246     */
247    public static function attachment_link( $link, $post_id ) {
248        $url = self::video_url( $post_id );
249        return '' === $url ? $link : $url;
250    }
251
252    /**
253     * Playlist entries that open a video link to its channel page when the
254     * video belongs to this site.
255     *
256     * @param string $url  The entry URL ( videopress.com by default ).
257     * @param string $guid The video GUID.
258     * @return string
259     */
260    public static function playlist_entry_url( $url, $guid ) {
261        $id = self::attachment_id( $guid );
262        return $id ? self::video_url( $id ) : $url;
263    }
264
265    /**
266     * The video a block refers to: the Query Loop entry when it is a
267     * VideoPress attachment, else the queried attachment.
268     *
269     * @param int $context_post_id The block's postId context, if any.
270     * @return WP_Post|null
271     */
272    public static function current_video( $context_post_id = 0 ) {
273        $candidates = array( (int) $context_post_id, (int) get_the_ID() );
274        $queried    = get_queried_object();
275        if ( $queried instanceof WP_Post ) {
276            $candidates[] = (int) $queried->ID;
277        }
278        foreach ( $candidates as $id ) {
279            $post = $id ? get_post( $id ) : null;
280            if ( $post instanceof WP_Post && '' !== self::guid( $post ) ) {
281                return $post;
282            }
283        }
284        return null;
285    }
286
287    /**
288     * A video block with `useQueriedVideo` plays the current video. Its title
289     * and poster are read from VideoPress at render time, not from the blog.
290     *
291     * @param array $parsed_block The block being rendered.
292     * @return array
293     */
294    public static function queried_video_block( $parsed_block ) {
295        if ( ! isset( $parsed_block['blockName'] ) || 'videopress/video' !== $parsed_block['blockName'] || empty( $parsed_block['attrs']['useQueriedVideo'] ) ) {
296            return $parsed_block;
297        }
298        $post = self::current_video();
299        $guid = $post instanceof WP_Post ? self::guid( $post ) : '';
300        if ( ! $post instanceof WP_Post || '' === $guid ) {
301            $parsed_block['attrs']['guid'] = '';
302            return $parsed_block;
303        }
304        $details = function_exists( 'videopress_get_video_details' ) ? videopress_get_video_details( $guid ) : null;
305        $details = is_object( $details ) && ! is_wp_error( $details ) ? $details : null;
306
307        $parsed_block['attrs']['guid']  = $guid;
308        $parsed_block['attrs']['id']    = $post->ID;
309        $parsed_block['attrs']['src']   = 'https://videopress.com/v/' . $guid;
310        $parsed_block['attrs']['title'] = $details && ! empty( $details->title ) ? (string) $details->title : get_the_title( $post );
311        if ( $details && ! empty( $details->poster ) ) {
312            $parsed_block['attrs']['poster'] = (string) $details->poster;
313        }
314        return $parsed_block;
315    }
316
317    /**
318     * Whether this request is a channel video page.
319     *
320     * @return bool
321     */
322    public static function is_video_page() {
323        return (bool) get_query_var( self::QUERY_VAR );
324    }
325
326    /**
327     * The GUID of the video page's video, or an empty string.
328     *
329     * @return string
330     */
331    public static function queried_guid() {
332        if ( ! self::is_video_page() ) {
333            return '';
334        }
335        $post = self::current_video();
336        return $post instanceof WP_Post ? self::guid( $post ) : '';
337    }
338}