Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
96.43% covered (success)
96.43%
81 / 84
76.92% covered (warning)
76.92%
10 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
Channel
96.43% covered (success)
96.43%
81 / 84
76.92% covered (warning)
76.92%
10 / 13
62
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%
8 / 8
100.00% covered (success)
100.00%
1 / 1
11
 video_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
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        $id = function_exists( 'videopress_get_post_id_by_guid' ) ? videopress_get_post_id_by_guid( $guid ) : false;
129        if ( ! is_int( $id ) && function_exists( 'video_get_info_by_guid' ) ) {
130            $info = video_get_info_by_guid( $guid );
131            if ( is_object( $info ) && ! empty( $info->post_id ) && (int) $info->blog_id === get_current_blog_id() ) {
132                $id = (int) $info->post_id;
133            }
134        }
135        return is_int( $id ) && $id > 0 ? $id : 0;
136    }
137
138    /**
139     * The channel page of a video: `/videopress?v=<guid>`.
140     *
141     * @param int|WP_Post $attachment The attachment.
142     * @return string Empty when the attachment is not a VideoPress video.
143     */
144    public static function video_url( $attachment ) {
145        $guid = self::guid( $attachment );
146        return '' === $guid ? '' : add_query_arg( 'v', $guid, user_trailingslashit( home_url( '/' . self::ENDPOINT ) ) );
147    }
148
149    /**
150     * Public query vars: `v` selects the video, `videopress_video` marks the page.
151     *
152     * @param string[] $vars Query vars.
153     * @return string[]
154     */
155    public static function query_vars( $vars ) {
156        $vars[] = 'v';
157        $vars[] = self::QUERY_VAR;
158        return $vars;
159    }
160
161    /**
162     * `/videopress?v=<guid>` becomes the attachment's request, so the main
163     * query, the template context and the post blocks all see the video.
164     * An unknown GUID 404s.
165     *
166     * @param WP $wp The request.
167     * @return void
168     */
169    public static function route_video_page( $wp ) {
170        $path = trim( (string) $wp->request, '/' );
171        if ( self::ENDPOINT !== $path && empty( $wp->query_vars[ self::QUERY_VAR ] ) ) {
172            return;
173        }
174        $guid = isset( $_GET['v'] ) ? sanitize_text_field( wp_unslash( $_GET['v'] ) ) : ( $wp->query_vars['v'] ?? '' ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- public URL.
175        $id   = self::attachment_id( $guid );
176        if ( ! $id ) {
177            $wp->query_vars = array( 'error' => '404' );
178            return;
179        }
180        $wp->query_vars = array(
181            'attachment_id' => $id,
182            'post_type'     => 'attachment',
183            self::QUERY_VAR => 1,
184            'v'             => $guid,
185        );
186    }
187
188    /**
189     * Video pages render the theme's `videopress-video` block template.
190     *
191     * @param string[] $templates Template hierarchy.
192     * @return string[]
193     */
194    public static function template_hierarchy( $templates ) {
195        if ( get_query_var( self::QUERY_VAR ) ) {
196            array_unshift( $templates, self::TEMPLATE . '.php' );
197        }
198        return $templates;
199    }
200
201    /**
202     * VideoPress attachments link to their channel page.
203     *
204     * @param string $link    Attachment permalink.
205     * @param int    $post_id The attachment.
206     * @return string
207     */
208    public static function attachment_link( $link, $post_id ) {
209        $url = self::video_url( $post_id );
210        return '' === $url ? $link : $url;
211    }
212
213    /**
214     * Playlist entries that open a video link to its channel page when the
215     * video belongs to this site.
216     *
217     * @param string $url  The entry URL ( videopress.com by default ).
218     * @param string $guid The video GUID.
219     * @return string
220     */
221    public static function playlist_entry_url( $url, $guid ) {
222        $id = self::attachment_id( $guid );
223        return $id ? self::video_url( $id ) : $url;
224    }
225
226    /**
227     * The video a block refers to: the Query Loop entry when it is a
228     * VideoPress attachment, else the queried attachment.
229     *
230     * @param int $context_post_id The block's postId context, if any.
231     * @return WP_Post|null
232     */
233    public static function current_video( $context_post_id = 0 ) {
234        $candidates = array( (int) $context_post_id, (int) get_the_ID() );
235        $queried    = get_queried_object();
236        if ( $queried instanceof WP_Post ) {
237            $candidates[] = (int) $queried->ID;
238        }
239        foreach ( $candidates as $id ) {
240            $post = $id ? get_post( $id ) : null;
241            if ( $post instanceof WP_Post && '' !== self::guid( $post ) ) {
242                return $post;
243            }
244        }
245        return null;
246    }
247
248    /**
249     * A video block with `useQueriedVideo` plays the current video. Its title
250     * and poster are read from VideoPress at render time, not from the blog.
251     *
252     * @param array $parsed_block The block being rendered.
253     * @return array
254     */
255    public static function queried_video_block( $parsed_block ) {
256        if ( ! isset( $parsed_block['blockName'] ) || 'videopress/video' !== $parsed_block['blockName'] || empty( $parsed_block['attrs']['useQueriedVideo'] ) ) {
257            return $parsed_block;
258        }
259        $post = self::current_video();
260        $guid = $post instanceof WP_Post ? self::guid( $post ) : '';
261        if ( ! $post instanceof WP_Post || '' === $guid ) {
262            $parsed_block['attrs']['guid'] = '';
263            return $parsed_block;
264        }
265        $details = function_exists( 'videopress_get_video_details' ) ? videopress_get_video_details( $guid ) : null;
266        $details = is_object( $details ) && ! is_wp_error( $details ) ? $details : null;
267
268        $parsed_block['attrs']['guid']  = $guid;
269        $parsed_block['attrs']['id']    = $post->ID;
270        $parsed_block['attrs']['src']   = 'https://videopress.com/v/' . $guid;
271        $parsed_block['attrs']['title'] = $details && ! empty( $details->title ) ? (string) $details->title : get_the_title( $post );
272        if ( $details && ! empty( $details->poster ) ) {
273            $parsed_block['attrs']['poster'] = (string) $details->poster;
274        }
275        return $parsed_block;
276    }
277}