Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
65.36% covered (warning)
65.36%
183 / 280
34.62% covered (danger)
34.62%
9 / 26
CRAP
0.00% covered (danger)
0.00%
0 / 1
Transcript_Index
65.36% covered (warning)
65.36%
183 / 280
34.62% covered (danger)
34.62%
9 / 26
643.88
0.00% covered (danger)
0.00%
0 / 1
 init
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
2.00
 is_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 languages
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 primary_language
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 schedule
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 track_changed
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 attachment_meta_changed
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 schedule_reconciliation
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 reconcile
0.00% covered (danger)
0.00%
0 / 46
0.00% covered (danger)
0.00%
0 / 1
42
 index_attachment
92.50% covered (success)
92.50%
37 / 40
0.00% covered (danger)
0.00%
0 / 1
17.12
 clear
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 state
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 cues
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 select_source
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
5
 edited_tracks
96.00% covered (success)
96.00%
24 / 25
0.00% covered (danger)
0.00%
0 / 1
6
 videopress_tracks
90.91% covered (success)
90.91%
20 / 22
0.00% covered (danger)
0.00%
0 / 1
16.19
 fetch_track
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 parse_vtt
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
8
 cues_from_blocks
83.33% covered (warning)
83.33%
15 / 18
0.00% covered (danger)
0.00%
0 / 1
8.30
 timestamp_ms
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 clean_cue_text
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 text
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 register_rest_routes
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 rest_can_read_transcript
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
20
 is_public_video
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
42
 rest_get_transcript
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * VideoPress transcript index: a searchable projection of a video's selected caption track.
4 *
5 * @package automattic/jetpack-videopress
6 */
7
8namespace Automattic\Jetpack\VideoPress;
9
10use WP_Error;
11use WP_Post;
12use WP_REST_Request;
13use WP_REST_Server;
14
15/**
16 * Keeps, per video attachment, the plain text and cues of one caption track: the
17 * site's published edited track for the channel language when there is one,
18 * else the VideoPress track ( uploaded before generated ). Caption tracks stay
19 * authoritative; the projection is rebuildable and never returned by the media API.
20 */
21class Transcript_Index {
22
23    const META_STATE   = '_videopress_transcript';
24    const META_TEXT    = '_videopress_transcript_text';
25    const META_CUES    = '_videopress_transcript_cues';
26    const META_CHECKED = '_videopress_transcript_checked';
27
28    /**
29     * Bumped when the projection's shape or normalization changes, so reconciliation rebuilds it.
30     */
31    const INDEX_VERSION = 1;
32
33    const INDEX_HOOK     = 'videopress_transcript_index';
34    const RECONCILE_HOOK = 'videopress_transcript_reconcile';
35
36    const INDEX_DELAY      = 30;
37    const RECONCILE_BATCH  = 25;
38    const RECHECK_AFTER    = WEEK_IN_SECONDS;
39    const MAX_TRACK_BYTES  = 2097152;
40    const MAX_TEXT_BYTES   = 262144;
41    const MAX_CUES         = 20000;
42    const FETCH_TIMEOUT    = 10;
43    const TRACK_KINDS      = array( 'subtitles', 'captions' );
44    const STATUS_INDEXED   = 'indexed';
45    const STATUS_EMPTY     = 'empty';
46    const STATUS_NONE      = 'none';
47    const STATUS_ERROR     = 'error';
48    const SOURCE_EDITED    = 'edited';
49    const SOURCE_UPLOADED  = 'uploaded';
50    const SOURCE_GENERATED = 'generated';
51
52    /**
53     * Whether init() already ran.
54     *
55     * @var bool
56     */
57    private static $initialized = false;
58
59    /**
60     * Hook the index up. Safe to call from both the package initializer and the WordPress.com block bootstrap.
61     *
62     * @return void
63     */
64    public static function init() {
65        if ( self::$initialized ) {
66            return;
67        }
68        self::$initialized = true;
69
70        add_action( self::INDEX_HOOK, array( __CLASS__, 'index_attachment' ) );
71        add_action( self::RECONCILE_HOOK, array( __CLASS__, 'reconcile' ) );
72        add_action( 'save_post_' . Caption_Tracks::POST_TYPE, array( __CLASS__, 'track_changed' ) );
73        add_action( 'before_delete_post', array( __CLASS__, 'track_changed' ) );
74        add_action( 'trashed_post', array( __CLASS__, 'track_changed' ) );
75        add_action( 'add_attachment', array( __CLASS__, 'schedule' ) );
76        add_action( 'added_post_meta', array( __CLASS__, 'attachment_meta_changed' ), 10, 3 );
77        add_action( 'updated_post_meta', array( __CLASS__, 'attachment_meta_changed' ), 10, 3 );
78        add_action( 'init', array( __CLASS__, 'schedule_reconciliation' ), 20 );
79    }
80
81    /**
82     * Whether transcript indexing is enabled for this site.
83     *
84     * @return bool
85     */
86    public static function is_enabled() {
87        /**
88         * Filters whether VideoPress keeps a searchable transcript of each video.
89         *
90         * @since $$next-version$$
91         *
92         * @param bool $enabled Defaults to whether the channel feature is enabled.
93         */
94        return (bool) apply_filters( 'videopress_transcript_index_enabled', Channel::is_enabled() );
95    }
96
97    /**
98     * The languages a transcript is taken from, in order of preference.
99     *
100     * @return string[] Lowercase primary language subtags, e.g. array( 'en' ).
101     */
102    public static function languages() {
103        $locale = function_exists( 'get_locale' ) ? (string) get_locale() : 'en';
104
105        /**
106         * Filters the languages, in order, whose caption track becomes a video's transcript.
107         * A video with tracks in none of them has no transcript; there is no implicit fallback.
108         *
109         * @since $$next-version$$
110         *
111         * @param string[] $languages Defaults to the site language.
112         */
113        $languages = (array) apply_filters( 'videopress_transcript_languages', array( strtolower( (string) strtok( $locale, '_-' ) ) ) );
114        return array_values( array_unique( array_filter( array_map( array( __CLASS__, 'primary_language' ), $languages ) ) ) );
115    }
116
117    /**
118     * The primary subtag of a language tag or VideoPress track key ( "auto_en", "pt-BR" ).
119     *
120     * @param mixed $tag Language tag.
121     * @return string
122     */
123    public static function primary_language( $tag ) {
124        $tag = strtolower( (string) $tag );
125        $tag = preg_replace( '/^auto[_-]/', '', $tag );
126        return preg_match( '/^([a-z]{2,3})(?:[_-]|$)/', $tag, $match ) ? $match[1] : '';
127    }
128
129    /**
130     * Queue an index run for a video attachment.
131     *
132     * @param int $attachment_id Attachment ID.
133     * @return void
134     */
135    public static function schedule( $attachment_id ) {
136        $attachment_id = (int) $attachment_id;
137        if ( $attachment_id <= 0 || ! self::is_enabled() || wp_next_scheduled( self::INDEX_HOOK, array( $attachment_id ) ) ) {
138            return;
139        }
140        wp_schedule_single_event( time() + self::INDEX_DELAY, self::INDEX_HOOK, array( $attachment_id ) );
141    }
142
143    /**
144     * A caption track was saved, trashed or deleted: reindex its video.
145     *
146     * @param int $post_id Post ID.
147     * @return void
148     */
149    public static function track_changed( $post_id ) {
150        $post = get_post( $post_id );
151        if ( ! $post instanceof WP_Post || Caption_Tracks::POST_TYPE !== $post->post_type ) {
152            return;
153        }
154        $attachment_id = Channel::attachment_id( (string) get_post_meta( $post->ID, Caption_Tracks::META_GUID, true ) );
155        if ( $attachment_id ) {
156            self::schedule( $attachment_id );
157        }
158    }
159
160    /**
161     * VideoPress synchronized a video's metadata ( new tracks arrive this way ): reindex it.
162     *
163     * @param int    $meta_id   Meta ID.
164     * @param int    $object_id Post ID.
165     * @param string $meta_key  Meta key.
166     * @return void
167     */
168    public static function attachment_meta_changed( $meta_id, $object_id, $meta_key ) {
169        if ( '_wp_attachment_metadata' === $meta_key && '' !== Channel::guid( $object_id ) ) {
170            self::schedule( $object_id );
171        }
172    }
173
174    /**
175     * Run reconciliation daily, so a missed notification is recovered.
176     *
177     * @return void
178     */
179    public static function schedule_reconciliation() {
180        if ( self::is_enabled() && ! wp_next_scheduled( self::RECONCILE_HOOK ) ) {
181            wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::RECONCILE_HOOK );
182        }
183    }
184
185    /**
186     * Index a bounded batch of videos never checked, or not checked for a week.
187     *
188     * @param int $limit Most videos to index.
189     * @return int[] Indexed attachment IDs.
190     */
191    public static function reconcile( $limit = self::RECONCILE_BATCH ) {
192        if ( ! self::is_enabled() ) {
193            return array();
194        }
195        $limit = max( 1, (int) $limit );
196        $base  = array(
197            'post_type'      => 'attachment',
198            'post_status'    => 'inherit',
199            'post_mime_type' => defined( 'IS_WPCOM' ) && IS_WPCOM ? 'video' : Channel::MIME_TYPE,
200            'fields'         => 'ids',
201            'no_found_rows'  => true,
202        );
203        $ids   = get_posts(
204            $base + array(
205                'posts_per_page' => $limit,
206                'orderby'        => 'ID',
207                'order'          => 'DESC',
208                'meta_query'     => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded daily batch.
209                    array(
210                        'key'     => self::META_CHECKED,
211                        'compare' => 'NOT EXISTS',
212                    ),
213                ),
214            )
215        );
216        if ( count( $ids ) < $limit ) {
217            $ids = array_merge(
218                $ids,
219                get_posts(
220                    $base + array(
221                        'posts_per_page' => $limit - count( $ids ),
222                        'meta_key'       => self::META_CHECKED, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Bounded daily batch.
223                        'orderby'        => 'meta_value_num',
224                        'order'          => 'ASC',
225                        'meta_query'     => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded daily batch.
226                            array(
227                                'key'     => self::META_CHECKED,
228                                'value'   => time() - self::RECHECK_AFTER,
229                                'compare' => '<',
230                                'type'    => 'NUMERIC',
231                            ),
232                        ),
233                    )
234                )
235            );
236        }
237        foreach ( $ids as $id ) {
238            self::index_attachment( $id );
239        }
240        return array_map( 'intval', $ids );
241    }
242
243    /**
244     * Rebuild one video's projection from its currently selected track.
245     *
246     * @param int $attachment_id Attachment ID.
247     * @return array The stored state.
248     */
249    public static function index_attachment( $attachment_id ) {
250        $attachment_id = (int) $attachment_id;
251        $guid          = Channel::guid( $attachment_id );
252        if ( '' === $guid || ! self::is_enabled() ) {
253            return array();
254        }
255        update_post_meta( $attachment_id, self::META_CHECKED, time() );
256
257        $source = self::select_source( $guid );
258        if ( null === $source ) {
259            return self::clear( $attachment_id );
260        }
261
262        $cues = $source['cues'] ?? null;
263        if ( null === $cues ) {
264            $body = self::fetch_track( $source['url'] );
265            if ( is_wp_error( $body ) ) {
266                // Keep the last good projection; a transient fetch failure must not erase search text.
267                $state           = self::state( $attachment_id );
268                $state['status'] = $state ? $state['status'] : self::STATUS_ERROR;
269                $state['error']  = $body->get_error_code();
270                update_post_meta( $attachment_id, self::META_STATE, $state );
271                return $state;
272            }
273            $source['revision'] = sha1( $body );
274            $cues               = self::parse_vtt( $body );
275        }
276
277        // A newer track may have been published while this one was fetched; that change has its own job.
278        $current = self::select_source( $guid );
279        if ( null === $current || $current['key'] !== $source['key'] || ( isset( $current['revision'] ) && isset( $source['cues'] ) && $current['revision'] !== $source['revision'] ) ) {
280            return self::state( $attachment_id );
281        }
282
283        $previous = self::state( $attachment_id );
284        if ( $previous && self::INDEX_VERSION === ( $previous['version'] ?? 0 ) && $source['revision'] === ( $previous['revision'] ?? '' ) && $source['key'] === ( $previous['track'] ?? '' ) ) {
285            return $previous;
286        }
287
288        $text  = self::text( $cues );
289        $state = array(
290            'version'  => self::INDEX_VERSION,
291            'status'   => '' === $text ? self::STATUS_EMPTY : self::STATUS_INDEXED,
292            'source'   => $source['source'],
293            'language' => $source['language'],
294            'track'    => $source['key'],
295            'revision' => $source['revision'],
296            'cues'     => count( $cues ),
297            'updated'  => time(),
298        );
299        update_post_meta( $attachment_id, self::META_CUES, $cues );
300        update_post_meta( $attachment_id, self::META_TEXT, wp_slash( $text ) );
301        update_post_meta( $attachment_id, self::META_STATE, $state );
302        return $state;
303    }
304
305    /**
306     * Drop a video's projection when it no longer has a selected track.
307     *
308     * @param int $attachment_id Attachment ID.
309     * @return array The stored state.
310     */
311    private static function clear( $attachment_id ) {
312        delete_post_meta( $attachment_id, self::META_CUES );
313        delete_post_meta( $attachment_id, self::META_TEXT );
314        $state = array(
315            'version' => self::INDEX_VERSION,
316            'status'  => self::STATUS_NONE,
317            'updated' => time(),
318        );
319        update_post_meta( $attachment_id, self::META_STATE, $state );
320        return $state;
321    }
322
323    /**
324     * A video's index state.
325     *
326     * @param int $attachment_id Attachment ID.
327     * @return array Empty when never indexed.
328     */
329    public static function state( $attachment_id ) {
330        $state = get_post_meta( (int) $attachment_id, self::META_STATE, true );
331        return is_array( $state ) ? $state : array();
332    }
333
334    /**
335     * A video's indexed cues.
336     *
337     * @param int $attachment_id Attachment ID.
338     * @return array[] Cues with start and end ( ms ) and text.
339     */
340    public static function cues( $attachment_id ) {
341        $cues = get_post_meta( (int) $attachment_id, self::META_CUES, true );
342        return is_array( $cues ) ? $cues : array();
343    }
344
345    /**
346     * The track a video's transcript comes from, by language preference: the site's published
347     * edited track first, then a VideoPress track uploaded by the owner, then a generated one.
348     *
349     * @param string $guid VideoPress GUID.
350     * @return array|null Source with source, language, key, revision and cues ( edited ) or url ( VideoPress ).
351     */
352    public static function select_source( $guid ) {
353        $edited = self::edited_tracks( $guid );
354        $remote = self::videopress_tracks( $guid );
355
356        foreach ( self::languages() as $language ) {
357            if ( isset( $edited[ $language ] ) ) {
358                return $edited[ $language ];
359            }
360            foreach ( array( self::SOURCE_UPLOADED, self::SOURCE_GENERATED ) as $origin ) {
361                if ( isset( $remote[ $origin ][ $language ] ) ) {
362                    return $remote[ $origin ][ $language ];
363                }
364            }
365        }
366        return null;
367    }
368
369    /**
370     * The site's published edited caption tracks of a video, keyed by language.
371     *
372     * @param string $guid VideoPress GUID.
373     * @return array<string, array>
374     */
375    private static function edited_tracks( $guid ) {
376        $posts  = get_posts(
377            array(
378                'post_type'      => Caption_Tracks::POST_TYPE,
379                'post_status'    => 'publish',
380                'posts_per_page' => 20,
381                'orderby'        => 'modified',
382                'order'          => 'DESC',
383                'meta_key'       => Caption_Tracks::META_GUID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One video's tracks.
384                'meta_value'     => $guid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- One video's tracks.
385            )
386        );
387        $tracks = array();
388        foreach ( $posts as $post ) {
389            $kind     = (string) get_post_meta( $post->ID, Caption_Tracks::META_KIND, true );
390            $language = self::primary_language( get_post_meta( $post->ID, Caption_Tracks::META_SRC_LANG, true ) );
391            if ( '' === $language || isset( $tracks[ $language ] ) || ( '' !== $kind && ! in_array( $kind, self::TRACK_KINDS, true ) ) ) {
392                continue;
393            }
394            $tracks[ $language ] = array(
395                'source'   => self::SOURCE_EDITED,
396                'language' => $language,
397                'key'      => 'edited:' . $post->ID,
398                'revision' => sha1( $post->post_modified_gmt . '|' . $post->post_content ),
399                'cues'     => self::cues_from_blocks( $post->post_content ),
400            );
401        }
402        return $tracks;
403    }
404
405    /**
406     * A video's VideoPress subtitle and caption tracks, keyed by origin and language.
407     *
408     * @param string $guid VideoPress GUID.
409     * @return array{uploaded?: array<string, array>, generated?: array<string, array>}
410     */
411    private static function videopress_tracks( $guid ) {
412        $details = function_exists( 'videopress_get_video_details' ) ? videopress_get_video_details( $guid ) : null;
413
414        /**
415         * Filters the video details whose `tracks` and `file_url_base` the transcript index reads.
416         *
417         * @since $$next-version$$
418         *
419         * @param object|null $details VideoPress video details, or null when unavailable.
420         * @param string      $guid    VideoPress GUID.
421         */
422        $details = apply_filters( 'videopress_transcript_video_details', is_object( $details ) && ! is_wp_error( $details ) ? $details : null, $guid );
423        if ( ! is_object( $details ) || empty( $details->tracks ) ) {
424            return array();
425        }
426        $details = json_decode( (string) wp_json_encode( $details, JSON_UNESCAPED_SLASHES ), true );
427        $base    = (string) ( $details['file_url_base']['https'] ?? '' );
428
429        $tracks = array();
430        foreach ( self::TRACK_KINDS as $kind ) {
431            foreach ( (array) ( $details['tracks'][ $kind ] ?? array() ) as $key => $track ) {
432                $src      = is_array( $track ) ? (string) ( $track['src'] ?? '' ) : '';
433                $language = self::primary_language( $key );
434                $url      = preg_match( '#^https://#', $src ) ? $src : ( '' !== $base && '' !== $src ? $base . rawurlencode( $src ) : '' );
435                $origin   = preg_match( '/^auto[_-]/i', (string) $key ) ? self::SOURCE_GENERATED : self::SOURCE_UPLOADED;
436                if ( '' === $language || '' === $url || isset( $tracks[ $origin ][ $language ] ) ) {
437                    continue;
438                }
439                $tracks[ $origin ][ $language ] = array(
440                    'source'   => $origin,
441                    'language' => $language,
442                    'key'      => $kind . ':' . $key,
443                    'url'      => $url,
444                );
445            }
446        }
447        return $tracks;
448    }
449
450    /**
451     * Download a WebVTT track.
452     *
453     * @param string $url Track URL.
454     * @return string|WP_Error The body.
455     */
456    private static function fetch_track( $url ) {
457        $response = wp_safe_remote_get(
458            $url,
459            array(
460                'timeout'             => self::FETCH_TIMEOUT,
461                'limit_response_size' => self::MAX_TRACK_BYTES,
462            )
463        );
464        if ( is_wp_error( $response ) ) {
465            return $response;
466        }
467        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
468            return new WP_Error( 'videopress_transcript_fetch_failed', 'HTTP ' . (int) wp_remote_retrieve_response_code( $response ) );
469        }
470        return (string) wp_remote_retrieve_body( $response );
471    }
472
473    /**
474     * Parse a WebVTT document into cues.
475     *
476     * @param string $vtt WebVTT text.
477     * @return array[] Cues with start and end in milliseconds and plain text.
478     */
479    public static function parse_vtt( $vtt ) {
480        $vtt    = str_replace( array( "\r\n", "\r" ), "\n", preg_replace( '/^\xEF\xBB\xBF/', '', (string) $vtt ) );
481        $blocks = preg_split( '/\n{2,}/', trim( $vtt ) );
482        $cues   = array();
483        foreach ( $blocks as $block ) {
484            $lines = explode( "\n", trim( $block ) );
485            // A cue identifier may precede the timing line; headers, NOTE, STYLE and REGION blocks have none.
486            while ( $lines && false === strpos( $lines[0], '-->' ) ) {
487                array_shift( $lines );
488            }
489            if ( ! $lines || ! preg_match( '/^\s*((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})\s+-->\s+((?:\d+:)?\d{1,2}:\d{2}[.,]\d{1,3})/', $lines[0], $timing ) ) {
490                continue;
491            }
492            $text = self::clean_cue_text( implode( ' ', array_slice( $lines, 1 ) ) );
493            if ( '' === $text ) {
494                continue;
495            }
496            $cues[] = array(
497                'start' => self::timestamp_ms( $timing[1] ),
498                'end'   => self::timestamp_ms( $timing[2] ),
499                'text'  => $text,
500            );
501            if ( count( $cues ) >= self::MAX_CUES ) {
502                break;
503            }
504        }
505        return $cues;
506    }
507
508    /**
509     * Cues from an edited track's caption-cue blocks.
510     *
511     * @param string $content Serialized blocks.
512     * @return array[]
513     */
514    public static function cues_from_blocks( $content ) {
515        $cues = array();
516        foreach ( parse_blocks( (string) $content ) as $block ) {
517            if ( Caption_Tracks::CUE_BLOCK_NAME !== ( $block['blockName'] ?? '' ) ) {
518                continue;
519            }
520            $attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
521            $text  = self::clean_cue_text( (string) ( $attrs['text'] ?? '' ) );
522            $start = self::timestamp_ms( (string) ( $attrs['startTime'] ?? '' ) );
523            if ( '' === $text || null === $start ) {
524                continue;
525            }
526            $end    = self::timestamp_ms( (string) ( $attrs['endTime'] ?? '' ) );
527            $cues[] = array(
528                'start' => $start,
529                'end'   => null === $end ? $start : $end,
530                'text'  => $text,
531            );
532            if ( count( $cues ) >= self::MAX_CUES ) {
533                break;
534            }
535        }
536        return $cues;
537    }
538
539    /**
540     * Milliseconds of a WebVTT timestamp ( "01:02:03.456" or "02:03.456" ).
541     *
542     * @param string $timestamp Timestamp.
543     * @return int|null
544     */
545    public static function timestamp_ms( $timestamp ) {
546        if ( ! preg_match( '/^\s*(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?\s*$/', (string) $timestamp, $m ) ) {
547            return null;
548        }
549        return ( ( (int) $m[1] * 3600 + (int) $m[2] * 60 + (int) $m[3] ) * 1000 ) + (int) str_pad( $m[4] ?? '0', 3, '0' );
550    }
551
552    /**
553     * Plain cue text: no voice or styling tags, entities decoded, whitespace collapsed.
554     *
555     * @param string $text Cue payload.
556     * @return string
557     */
558    private static function clean_cue_text( $text ) {
559        $text = preg_replace( '/<[^>]*>/', ' ', (string) $text );
560        $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
561        return trim( preg_replace( '/\s+/u', ' ', $text ) );
562    }
563
564    /**
565     * The searchable text of a set of cues, capped in size.
566     *
567     * @param array[] $cues Cues.
568     * @return string
569     */
570    public static function text( array $cues ) {
571        $text = trim( implode( ' ', array_column( $cues, 'text' ) ) );
572        if ( strlen( $text ) > self::MAX_TEXT_BYTES ) {
573            $text = function_exists( 'mb_strcut' ) ? mb_strcut( $text, 0, self::MAX_TEXT_BYTES, 'UTF-8' ) : substr( $text, 0, self::MAX_TEXT_BYTES );
574        }
575        return $text;
576    }
577
578    /**
579     * Register GET /videos/{guid}/transcript under Rest_Controller::REST_NAMESPACE.
580     *
581     * @return void
582     */
583    public static function register_rest_routes() {
584        register_rest_route(
585            Rest_Controller::REST_NAMESPACE,
586            '/videos/(?P<guid>[a-zA-Z0-9]{8})/transcript',
587            array(
588                'methods'             => WP_REST_Server::READABLE,
589                'callback'            => array( __CLASS__, 'rest_get_transcript' ),
590                'permission_callback' => array( __CLASS__, 'rest_can_read_transcript' ),
591            )
592        );
593    }
594
595    /**
596     * Transcripts are as visible as the video: anyone for a public video on a public site, members otherwise.
597     *
598     * @param WP_REST_Request $request Request.
599     * @return bool|WP_Error
600     */
601    public static function rest_can_read_transcript( WP_REST_Request $request ) {
602        $id = Channel::attachment_id( (string) $request['guid'] );
603        if ( ! $id || ! self::is_enabled() ) {
604            return new WP_Error( 'videopress_transcript_not_found', __( 'This video does not belong to this site.', 'jetpack-videopress-pkg' ), array( 'status' => 404 ) );
605        }
606        return self::is_public_video( $id ) || current_user_can( 'read' );
607    }
608
609    /**
610     * Whether a video can be watched by anyone.
611     *
612     * @param int $attachment_id Attachment ID.
613     * @return bool
614     */
615    public static function is_public_video( $attachment_id ) {
616        $meta    = wp_get_attachment_metadata( (int) $attachment_id );
617        $privacy = is_array( $meta ) ? ( $meta['videopress']['privacy_setting'] ?? $meta['videopress']['is_private'] ?? null ) : null;
618        $private = 1 === (int) $privacy || true === $privacy;
619        if ( ! $private && 2 === (int) $privacy ) {
620            $private = (bool) get_option( 'videopress_private_enabled_for_site', false );
621        }
622        // blog_public 0 only discourages search engines; -1 is a private site.
623        return ! $private && '-1' !== (string) get_option( 'blog_public', '1' );
624    }
625
626    /**
627     * GET /videos/{guid}/transcript: the selected language, revision, status and cues.
628     *
629     * @param WP_REST_Request $request Request.
630     * @return \WP_REST_Response
631     */
632    public static function rest_get_transcript( WP_REST_Request $request ) {
633        $id    = Channel::attachment_id( (string) $request['guid'] );
634        $state = self::state( $id );
635        return rest_ensure_response(
636            array(
637                'guid'     => (string) $request['guid'],
638                'status'   => $state['status'] ?? 'pending',
639                'language' => $state['language'] ?? '',
640                'source'   => $state['source'] ?? '',
641                'revision' => $state['revision'] ?? '',
642                'updated'  => $state['updated'] ?? 0,
643                'cues'     => self::cues( $id ),
644            )
645        );
646    }
647}