Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
65.36% |
183 / 280 |
|
34.62% |
9 / 26 |
CRAP | |
0.00% |
0 / 1 |
| Transcript_Index | |
65.36% |
183 / 280 |
|
34.62% |
9 / 26 |
643.88 | |
0.00% |
0 / 1 |
| init | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
2.00 | |||
| is_enabled | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| languages | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| primary_language | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
| schedule | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
| track_changed | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
20 | |||
| attachment_meta_changed | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| schedule_reconciliation | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| reconcile | |
0.00% |
0 / 46 |
|
0.00% |
0 / 1 |
42 | |||
| index_attachment | |
92.50% |
37 / 40 |
|
0.00% |
0 / 1 |
17.12 | |||
| clear | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| state | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| cues | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| select_source | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
5 | |||
| edited_tracks | |
96.00% |
24 / 25 |
|
0.00% |
0 / 1 |
6 | |||
| videopress_tracks | |
90.91% |
20 / 22 |
|
0.00% |
0 / 1 |
16.19 | |||
| fetch_track | |
91.67% |
11 / 12 |
|
0.00% |
0 / 1 |
3.01 | |||
| parse_vtt | |
95.00% |
19 / 20 |
|
0.00% |
0 / 1 |
8 | |||
| cues_from_blocks | |
83.33% |
15 / 18 |
|
0.00% |
0 / 1 |
8.30 | |||
| timestamp_ms | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| clean_cue_text | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| text | |
75.00% |
3 / 4 |
|
0.00% |
0 / 1 |
3.14 | |||
| register_rest_routes | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
1 | |||
| rest_can_read_transcript | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
20 | |||
| is_public_video | |
0.00% |
0 / 6 |
|
0.00% |
0 / 1 |
42 | |||
| rest_get_transcript | |
0.00% |
0 / 13 |
|
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 | |
| 8 | namespace Automattic\Jetpack\VideoPress; |
| 9 | |
| 10 | use WP_Error; |
| 11 | use WP_Post; |
| 12 | use WP_REST_Request; |
| 13 | use 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 | */ |
| 21 | class 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 | } |