Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
68.97% covered (warning)
68.97%
220 / 319
34.48% covered (danger)
34.48%
10 / 29
CRAP
0.00% covered (danger)
0.00%
0 / 1
Transcript_Index
68.97% covered (warning)
68.97%
220 / 319
34.48% covered (danger)
34.48%
10 / 29
592.04
0.00% covered (danger)
0.00%
0 / 1
 init
92.31% covered (success)
92.31%
12 / 13
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
 is_search_enabled
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 search_sql
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
6.01
 match
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
5
 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        add_filter( 'posts_search', array( __CLASS__, 'search_sql' ), 10, 2 );
80    }
81
82    /**
83     * Whether transcript indexing is enabled for this site.
84     *
85     * @return bool
86     */
87    public static function is_enabled() {
88        /**
89         * Filters whether VideoPress keeps a searchable transcript of each video.
90         *
91         * @since $$next-version$$
92         *
93         * @param bool $enabled Defaults to whether the channel feature is enabled.
94         */
95        return (bool) apply_filters( 'videopress_transcript_index_enabled', Channel::is_enabled() );
96    }
97
98    /**
99     * The languages a transcript is taken from, in order of preference.
100     *
101     * @return string[] Lowercase primary language subtags, e.g. array( 'en' ).
102     */
103    public static function languages() {
104        $locale = function_exists( 'get_locale' ) ? (string) get_locale() : 'en';
105
106        /**
107         * Filters the languages, in order, whose caption track becomes a video's transcript.
108         * A video with tracks in none of them has no transcript; there is no implicit fallback.
109         *
110         * @since $$next-version$$
111         *
112         * @param string[] $languages Defaults to the site language.
113         */
114        $languages = (array) apply_filters( 'videopress_transcript_languages', array( strtolower( (string) strtok( $locale, '_-' ) ) ) );
115        return array_values( array_unique( array_filter( array_map( array( __CLASS__, 'primary_language' ), $languages ) ) ) );
116    }
117
118    /**
119     * The primary subtag of a language tag or VideoPress track key ( "auto_en", "pt-BR" ).
120     *
121     * @param mixed $tag Language tag.
122     * @return string
123     */
124    public static function primary_language( $tag ) {
125        $tag = strtolower( (string) $tag );
126        $tag = preg_replace( '/^auto[_-]/', '', $tag );
127        return preg_match( '/^([a-z]{2,3})(?:[_-]|$)/', $tag, $match ) ? $match[1] : '';
128    }
129
130    /**
131     * Queue an index run for a video attachment.
132     *
133     * @param int $attachment_id Attachment ID.
134     * @return void
135     */
136    public static function schedule( $attachment_id ) {
137        $attachment_id = (int) $attachment_id;
138        if ( $attachment_id <= 0 || ! self::is_enabled() || wp_next_scheduled( self::INDEX_HOOK, array( $attachment_id ) ) ) {
139            return;
140        }
141        wp_schedule_single_event( time() + self::INDEX_DELAY, self::INDEX_HOOK, array( $attachment_id ) );
142    }
143
144    /**
145     * A caption track was saved, trashed or deleted: reindex its video.
146     *
147     * @param int $post_id Post ID.
148     * @return void
149     */
150    public static function track_changed( $post_id ) {
151        $post = get_post( $post_id );
152        if ( ! $post instanceof WP_Post || Caption_Tracks::POST_TYPE !== $post->post_type ) {
153            return;
154        }
155        $attachment_id = Channel::attachment_id( (string) get_post_meta( $post->ID, Caption_Tracks::META_GUID, true ) );
156        if ( $attachment_id ) {
157            self::schedule( $attachment_id );
158        }
159    }
160
161    /**
162     * VideoPress synchronized a video's metadata ( new tracks arrive this way ): reindex it.
163     *
164     * @param int    $meta_id   Meta ID.
165     * @param int    $object_id Post ID.
166     * @param string $meta_key  Meta key.
167     * @return void
168     */
169    public static function attachment_meta_changed( $meta_id, $object_id, $meta_key ) {
170        if ( '_wp_attachment_metadata' === $meta_key && '' !== Channel::guid( $object_id ) ) {
171            self::schedule( $object_id );
172        }
173    }
174
175    /**
176     * Run reconciliation daily, so a missed notification is recovered.
177     *
178     * @return void
179     */
180    public static function schedule_reconciliation() {
181        if ( self::is_enabled() && ! wp_next_scheduled( self::RECONCILE_HOOK ) ) {
182            wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::RECONCILE_HOOK );
183        }
184    }
185
186    /**
187     * Index a bounded batch of videos never checked, or not checked for a week.
188     *
189     * @param int $limit Most videos to index.
190     * @return int[] Indexed attachment IDs.
191     */
192    public static function reconcile( $limit = self::RECONCILE_BATCH ) {
193        if ( ! self::is_enabled() ) {
194            return array();
195        }
196        $limit = max( 1, (int) $limit );
197        $base  = array(
198            'post_type'      => 'attachment',
199            'post_status'    => 'inherit',
200            'post_mime_type' => defined( 'IS_WPCOM' ) && IS_WPCOM ? 'video' : Channel::MIME_TYPE,
201            'fields'         => 'ids',
202            'no_found_rows'  => true,
203        );
204        $ids   = get_posts(
205            $base + array(
206                'posts_per_page' => $limit,
207                'orderby'        => 'ID',
208                'order'          => 'DESC',
209                'meta_query'     => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded daily batch.
210                    array(
211                        'key'     => self::META_CHECKED,
212                        'compare' => 'NOT EXISTS',
213                    ),
214                ),
215            )
216        );
217        if ( count( $ids ) < $limit ) {
218            $ids = array_merge(
219                $ids,
220                get_posts(
221                    $base + array(
222                        'posts_per_page' => $limit - count( $ids ),
223                        'meta_key'       => self::META_CHECKED, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Bounded daily batch.
224                        'orderby'        => 'meta_value_num',
225                        'order'          => 'ASC',
226                        'meta_query'     => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Bounded daily batch.
227                            array(
228                                'key'     => self::META_CHECKED,
229                                'value'   => time() - self::RECHECK_AFTER,
230                                'compare' => '<',
231                                'type'    => 'NUMERIC',
232                            ),
233                        ),
234                    )
235                )
236            );
237        }
238        foreach ( $ids as $id ) {
239            self::index_attachment( $id );
240        }
241        return array_map( 'intval', $ids );
242    }
243
244    /**
245     * Rebuild one video's projection from its currently selected track.
246     *
247     * @param int $attachment_id Attachment ID.
248     * @return array The stored state.
249     */
250    public static function index_attachment( $attachment_id ) {
251        $attachment_id = (int) $attachment_id;
252        $guid          = Channel::guid( $attachment_id );
253        if ( '' === $guid || ! self::is_enabled() ) {
254            return array();
255        }
256        update_post_meta( $attachment_id, self::META_CHECKED, time() );
257
258        $source = self::select_source( $guid );
259        if ( null === $source ) {
260            return self::clear( $attachment_id );
261        }
262
263        $cues = $source['cues'] ?? null;
264        if ( null === $cues ) {
265            $body = self::fetch_track( $source['url'] );
266            if ( is_wp_error( $body ) ) {
267                // Keep the last good projection; a transient fetch failure must not erase search text.
268                $state           = self::state( $attachment_id );
269                $state['status'] = $state ? $state['status'] : self::STATUS_ERROR;
270                $state['error']  = $body->get_error_code();
271                update_post_meta( $attachment_id, self::META_STATE, $state );
272                return $state;
273            }
274            $source['revision'] = sha1( $body );
275            $cues               = self::parse_vtt( $body );
276        }
277
278        // A newer track may have been published while this one was fetched; that change has its own job.
279        $current = self::select_source( $guid );
280        if ( null === $current || $current['key'] !== $source['key'] || ( isset( $current['revision'] ) && isset( $source['cues'] ) && $current['revision'] !== $source['revision'] ) ) {
281            return self::state( $attachment_id );
282        }
283
284        $previous = self::state( $attachment_id );
285        if ( $previous && self::INDEX_VERSION === ( $previous['version'] ?? 0 ) && $source['revision'] === ( $previous['revision'] ?? '' ) && $source['key'] === ( $previous['track'] ?? '' ) ) {
286            return $previous;
287        }
288
289        $text  = self::text( $cues );
290        $state = array(
291            'version'  => self::INDEX_VERSION,
292            'status'   => '' === $text ? self::STATUS_EMPTY : self::STATUS_INDEXED,
293            'source'   => $source['source'],
294            'language' => $source['language'],
295            'track'    => $source['key'],
296            'revision' => $source['revision'],
297            'cues'     => count( $cues ),
298            'updated'  => time(),
299        );
300        update_post_meta( $attachment_id, self::META_CUES, $cues );
301        update_post_meta( $attachment_id, self::META_TEXT, wp_slash( $text ) );
302        update_post_meta( $attachment_id, self::META_STATE, $state );
303        return $state;
304    }
305
306    /**
307     * Drop a video's projection when it no longer has a selected track.
308     *
309     * @param int $attachment_id Attachment ID.
310     * @return array The stored state.
311     */
312    private static function clear( $attachment_id ) {
313        delete_post_meta( $attachment_id, self::META_CUES );
314        delete_post_meta( $attachment_id, self::META_TEXT );
315        $state = array(
316            'version' => self::INDEX_VERSION,
317            'status'  => self::STATUS_NONE,
318            'updated' => time(),
319        );
320        update_post_meta( $attachment_id, self::META_STATE, $state );
321        return $state;
322    }
323
324    /**
325     * A video's index state.
326     *
327     * @param int $attachment_id Attachment ID.
328     * @return array Empty when never indexed.
329     */
330    public static function state( $attachment_id ) {
331        $state = get_post_meta( (int) $attachment_id, self::META_STATE, true );
332        return is_array( $state ) ? $state : array();
333    }
334
335    /**
336     * A video's indexed cues.
337     *
338     * @param int $attachment_id Attachment ID.
339     * @return array[] Cues with start and end ( ms ) and text.
340     */
341    public static function cues( $attachment_id ) {
342        $cues = get_post_meta( (int) $attachment_id, self::META_CUES, true );
343        return is_array( $cues ) ? $cues : array();
344    }
345
346    /**
347     * The track a video's transcript comes from, by language preference: the site's published
348     * edited track first, then a VideoPress track uploaded by the owner, then a generated one.
349     *
350     * @param string $guid VideoPress GUID.
351     * @return array|null Source with source, language, key, revision and cues ( edited ) or url ( VideoPress ).
352     */
353    public static function select_source( $guid ) {
354        $edited = self::edited_tracks( $guid );
355        $remote = self::videopress_tracks( $guid );
356
357        foreach ( self::languages() as $language ) {
358            if ( isset( $edited[ $language ] ) ) {
359                return $edited[ $language ];
360            }
361            foreach ( array( self::SOURCE_UPLOADED, self::SOURCE_GENERATED ) as $origin ) {
362                if ( isset( $remote[ $origin ][ $language ] ) ) {
363                    return $remote[ $origin ][ $language ];
364                }
365            }
366        }
367        return null;
368    }
369
370    /**
371     * The site's published edited caption tracks of a video, keyed by language.
372     *
373     * @param string $guid VideoPress GUID.
374     * @return array<string, array>
375     */
376    private static function edited_tracks( $guid ) {
377        $posts  = get_posts(
378            array(
379                'post_type'      => Caption_Tracks::POST_TYPE,
380                'post_status'    => 'publish',
381                'posts_per_page' => 20,
382                'orderby'        => 'modified',
383                'order'          => 'DESC',
384                'meta_key'       => Caption_Tracks::META_GUID, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- One video's tracks.
385                'meta_value'     => $guid, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- One video's tracks.
386            )
387        );
388        $tracks = array();
389        foreach ( $posts as $post ) {
390            $kind     = (string) get_post_meta( $post->ID, Caption_Tracks::META_KIND, true );
391            $language = self::primary_language( get_post_meta( $post->ID, Caption_Tracks::META_SRC_LANG, true ) );
392            if ( '' === $language || isset( $tracks[ $language ] ) || ( '' !== $kind && ! in_array( $kind, self::TRACK_KINDS, true ) ) ) {
393                continue;
394            }
395            $tracks[ $language ] = array(
396                'source'   => self::SOURCE_EDITED,
397                'language' => $language,
398                'key'      => 'edited:' . $post->ID,
399                'revision' => sha1( $post->post_modified_gmt . '|' . $post->post_content ),
400                'cues'     => self::cues_from_blocks( $post->post_content ),
401            );
402        }
403        return $tracks;
404    }
405
406    /**
407     * A video's VideoPress subtitle and caption tracks, keyed by origin and language.
408     *
409     * @param string $guid VideoPress GUID.
410     * @return array{uploaded?: array<string, array>, generated?: array<string, array>}
411     */
412    private static function videopress_tracks( $guid ) {
413        $details = function_exists( 'videopress_get_video_details' ) ? videopress_get_video_details( $guid ) : null;
414
415        /**
416         * Filters the video details whose `tracks` and `file_url_base` the transcript index reads.
417         *
418         * @since $$next-version$$
419         *
420         * @param object|null $details VideoPress video details, or null when unavailable.
421         * @param string      $guid    VideoPress GUID.
422         */
423        $details = apply_filters( 'videopress_transcript_video_details', is_object( $details ) && ! is_wp_error( $details ) ? $details : null, $guid );
424        if ( ! is_object( $details ) || empty( $details->tracks ) ) {
425            return array();
426        }
427        $details = json_decode( (string) wp_json_encode( $details, JSON_UNESCAPED_SLASHES ), true );
428        $base    = (string) ( $details['file_url_base']['https'] ?? '' );
429
430        $tracks = array();
431        foreach ( self::TRACK_KINDS as $kind ) {
432            foreach ( (array) ( $details['tracks'][ $kind ] ?? array() ) as $key => $track ) {
433                $src      = is_array( $track ) ? (string) ( $track['src'] ?? '' ) : '';
434                $language = self::primary_language( $key );
435                $url      = preg_match( '#^https://#', $src ) ? $src : ( '' !== $base && '' !== $src ? $base . rawurlencode( $src ) : '' );
436                $origin   = preg_match( '/^auto[_-]/i', (string) $key ) ? self::SOURCE_GENERATED : self::SOURCE_UPLOADED;
437                if ( '' === $language || '' === $url || isset( $tracks[ $origin ][ $language ] ) ) {
438                    continue;
439                }
440                $tracks[ $origin ][ $language ] = array(
441                    'source'   => $origin,
442                    'language' => $language,
443                    'key'      => $kind . ':' . $key,
444                    'url'      => $url,
445                );
446            }
447        }
448        return $tracks;
449    }
450
451    /**
452     * Download a WebVTT track.
453     *
454     * @param string $url Track URL.
455     * @return string|WP_Error The body.
456     */
457    private static function fetch_track( $url ) {
458        $response = wp_safe_remote_get(
459            $url,
460            array(
461                'timeout'             => self::FETCH_TIMEOUT,
462                'limit_response_size' => self::MAX_TRACK_BYTES,
463            )
464        );
465        if ( is_wp_error( $response ) ) {
466            return $response;
467        }
468        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
469            return new WP_Error( 'videopress_transcript_fetch_failed', 'HTTP ' . (int) wp_remote_retrieve_response_code( $response ) );
470        }
471        return (string) wp_remote_retrieve_body( $response );
472    }
473
474    /**
475     * Parse a WebVTT document into cues.
476     *
477     * @param string $vtt WebVTT text.
478     * @return array[] Cues with start and end in milliseconds and plain text.
479     */
480    public static function parse_vtt( $vtt ) {
481        $vtt    = str_replace( array( "\r\n", "\r" ), "\n", preg_replace( '/^\xEF\xBB\xBF/', '', (string) $vtt ) );
482        $blocks = preg_split( '/\n{2,}/', trim( $vtt ) );
483        $cues   = array();
484        foreach ( $blocks as $block ) {
485            $lines = explode( "\n", trim( $block ) );
486            // A cue identifier may precede the timing line; headers, NOTE, STYLE and REGION blocks have none.
487            while ( $lines && false === strpos( $lines[0], '-->' ) ) {
488                array_shift( $lines );
489            }
490            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 ) ) {
491                continue;
492            }
493            $text = self::clean_cue_text( implode( ' ', array_slice( $lines, 1 ) ) );
494            if ( '' === $text ) {
495                continue;
496            }
497            $cues[] = array(
498                'start' => self::timestamp_ms( $timing[1] ),
499                'end'   => self::timestamp_ms( $timing[2] ),
500                'text'  => $text,
501            );
502            if ( count( $cues ) >= self::MAX_CUES ) {
503                break;
504            }
505        }
506        return $cues;
507    }
508
509    /**
510     * Cues from an edited track's caption-cue blocks.
511     *
512     * @param string $content Serialized blocks.
513     * @return array[]
514     */
515    public static function cues_from_blocks( $content ) {
516        $cues = array();
517        foreach ( parse_blocks( (string) $content ) as $block ) {
518            if ( Caption_Tracks::CUE_BLOCK_NAME !== ( $block['blockName'] ?? '' ) ) {
519                continue;
520            }
521            $attrs = is_array( $block['attrs'] ?? null ) ? $block['attrs'] : array();
522            $text  = self::clean_cue_text( (string) ( $attrs['text'] ?? '' ) );
523            $start = self::timestamp_ms( (string) ( $attrs['startTime'] ?? '' ) );
524            if ( '' === $text || null === $start ) {
525                continue;
526            }
527            $end    = self::timestamp_ms( (string) ( $attrs['endTime'] ?? '' ) );
528            $cues[] = array(
529                'start' => $start,
530                'end'   => null === $end ? $start : $end,
531                'text'  => $text,
532            );
533            if ( count( $cues ) >= self::MAX_CUES ) {
534                break;
535            }
536        }
537        return $cues;
538    }
539
540    /**
541     * Milliseconds of a WebVTT timestamp ( "01:02:03.456" or "02:03.456" ).
542     *
543     * @param string $timestamp Timestamp.
544     * @return int|null
545     */
546    public static function timestamp_ms( $timestamp ) {
547        if ( ! preg_match( '/^\s*(?:(\d+):)?(\d{1,2}):(\d{2})(?:[.,](\d{1,3}))?\s*$/', (string) $timestamp, $m ) ) {
548            return null;
549        }
550        return ( ( (int) $m[1] * 3600 + (int) $m[2] * 60 + (int) $m[3] ) * 1000 ) + (int) str_pad( $m[4] ?? '0', 3, '0' );
551    }
552
553    /**
554     * Plain cue text: no voice or styling tags, entities decoded, whitespace collapsed.
555     *
556     * @param string $text Cue payload.
557     * @return string
558     */
559    private static function clean_cue_text( $text ) {
560        $text = preg_replace( '/<[^>]*>/', ' ', (string) $text );
561        $text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
562        return trim( preg_replace( '/\s+/u', ' ', $text ) );
563    }
564
565    /**
566     * The searchable text of a set of cues, capped in size.
567     *
568     * @param array[] $cues Cues.
569     * @return string
570     */
571    public static function text( array $cues ) {
572        $text = trim( implode( ' ', array_column( $cues, 'text' ) ) );
573        if ( strlen( $text ) > self::MAX_TEXT_BYTES ) {
574            $text = function_exists( 'mb_strcut' ) ? mb_strcut( $text, 0, self::MAX_TEXT_BYTES, 'UTF-8' ) : substr( $text, 0, self::MAX_TEXT_BYTES );
575        }
576        return $text;
577    }
578
579    /**
580     * Whether channel searches should also match transcripts.
581     *
582     * @return bool
583     */
584    public static function is_search_enabled() {
585        /**
586         * Filters whether channel searches match words spoken in the videos.
587         *
588         * @since $$next-version$$
589         *
590         * @param bool $enabled Defaults to whether the transcript index is enabled.
591         */
592        return (bool) apply_filters( 'videopress_transcript_search_enabled', self::is_enabled() );
593    }
594
595    /**
596     * Portable transcript search: every search term must appear in the title, description or
597     * transcript, matched in SQL with the query's other constraints, before LIMIT and totals.
598     * Terms are literal ( LIKE wildcards escaped ); quoted phrases stay whole, as core parses them.
599     *
600     * @param string    $search The query's search SQL.
601     * @param \WP_Query $query  The query.
602     * @return string
603     */
604    public static function search_sql( $search, $query ) {
605        if ( ! $query instanceof \WP_Query || ! $query->get( 'videopress_transcript_search' ) || '' === $search ) {
606            return $search;
607        }
608        $terms = array_filter( (array) $query->get( 'search_terms' ), 'is_string' );
609        if ( ! $terms ) {
610            return $search;
611        }
612        global $wpdb;
613        $clauses = array();
614        foreach ( $terms as $term ) {
615            $like      = '%' . $wpdb->esc_like( $term ) . '%';
616            $clauses[] = $wpdb->prepare(
617                "({$wpdb->posts}.post_title LIKE %s OR {$wpdb->posts}.post_excerpt LIKE %s OR {$wpdb->posts}.post_content LIKE %s OR EXISTS ( SELECT 1 FROM {$wpdb->postmeta} vpt WHERE vpt.post_id = {$wpdb->posts}.ID AND vpt.meta_key = %s AND vpt.meta_value LIKE %s ))",
618                $like,
619                $like,
620                $like,
621                self::META_TEXT,
622                $like
623            );
624        }
625        return ' AND (' . implode( ' AND ', $clauses ) . ') ';
626    }
627
628    /**
629     * The first cue where a search matches the transcript: the whole search when it is
630     * spoken as a phrase, else the first cue holding its longest word.
631     *
632     * @param int    $attachment_id Attachment ID.
633     * @param string $search        Search text.
634     * @return array{start: int, text: string, needle: string}|null
635     */
636    public static function match( $attachment_id, $search ) {
637        $search = trim( preg_replace( '/\s+/u', ' ', str_replace( '"', ' ', (string) $search ) ) );
638        if ( '' === $search ) {
639            return null;
640        }
641        $cues  = self::cues( $attachment_id );
642        $words = preg_split( '/\s+/u', $search );
643        usort(
644            $words,
645            function ( $a, $b ) {
646                return strlen( $b ) - strlen( $a );
647            }
648        );
649        foreach ( array_unique( array( $search, $words[0] ) ) as $needle ) {
650            foreach ( $cues as $cue ) {
651                if ( false !== mb_stripos( (string) $cue['text'], $needle ) ) {
652                    return array(
653                        'start'  => (int) $cue['start'],
654                        'text'   => (string) $cue['text'],
655                        'needle' => $needle,
656                    );
657                }
658            }
659        }
660        return null;
661    }
662
663    /**
664     * Register GET /videos/{guid}/transcript under Rest_Controller::REST_NAMESPACE.
665     *
666     * @return void
667     */
668    public static function register_rest_routes() {
669        register_rest_route(
670            Rest_Controller::REST_NAMESPACE,
671            '/videos/(?P<guid>[a-zA-Z0-9]{8})/transcript',
672            array(
673                'methods'             => WP_REST_Server::READABLE,
674                'callback'            => array( __CLASS__, 'rest_get_transcript' ),
675                'permission_callback' => array( __CLASS__, 'rest_can_read_transcript' ),
676            )
677        );
678    }
679
680    /**
681     * Transcripts are as visible as the video: anyone for a public video on a public site, members otherwise.
682     *
683     * @param WP_REST_Request $request Request.
684     * @return bool|WP_Error
685     */
686    public static function rest_can_read_transcript( WP_REST_Request $request ) {
687        $id = Channel::attachment_id( (string) $request['guid'] );
688        if ( ! $id || ! self::is_enabled() ) {
689            return new WP_Error( 'videopress_transcript_not_found', __( 'This video does not belong to this site.', 'jetpack-videopress-pkg' ), array( 'status' => 404 ) );
690        }
691        return self::is_public_video( $id ) || current_user_can( 'read' );
692    }
693
694    /**
695     * Whether a video can be watched by anyone.
696     *
697     * @param int $attachment_id Attachment ID.
698     * @return bool
699     */
700    public static function is_public_video( $attachment_id ) {
701        $meta    = wp_get_attachment_metadata( (int) $attachment_id );
702        $privacy = is_array( $meta ) ? ( $meta['videopress']['privacy_setting'] ?? $meta['videopress']['is_private'] ?? null ) : null;
703        $private = 1 === (int) $privacy || true === $privacy;
704        if ( ! $private && 2 === (int) $privacy ) {
705            $private = (bool) get_option( 'videopress_private_enabled_for_site', false );
706        }
707        // blog_public 0 only discourages search engines; -1 is a private site.
708        return ! $private && '-1' !== (string) get_option( 'blog_public', '1' );
709    }
710
711    /**
712     * GET /videos/{guid}/transcript: the selected language, revision, status and cues.
713     *
714     * @param WP_REST_Request $request Request.
715     * @return \WP_REST_Response
716     */
717    public static function rest_get_transcript( WP_REST_Request $request ) {
718        $id    = Channel::attachment_id( (string) $request['guid'] );
719        $state = self::state( $id );
720        return rest_ensure_response(
721            array(
722                'guid'     => (string) $request['guid'],
723                'status'   => $state['status'] ?? 'pending',
724                'language' => $state['language'] ?? '',
725                'source'   => $state['source'] ?? '',
726                'revision' => $state['revision'] ?? '',
727                'updated'  => $state['updated'] ?? 0,
728                'cues'     => self::cues( $id ),
729            )
730        );
731    }
732}