├── .gitignore ├── .wordpressorg ├── banner-1544x500.png ├── banner-772x250.png ├── icon-128x128.png └── icon-256x256.png ├── CHANGELOG.md ├── automatic-featured-images-from-videos.php ├── includes ├── ajax.php ├── bulk-operations.php └── cli.php ├── js └── button.js └── readme.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | -------------------------------------------------------------------------------- /.wordpressorg/banner-1544x500.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevStudios/Automatic-Featured-Images-from-Videos/6806af8b0337f1d405eef3931fb8a5e2b33e78e6/.wordpressorg/banner-1544x500.png -------------------------------------------------------------------------------- /.wordpressorg/banner-772x250.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevStudios/Automatic-Featured-Images-from-Videos/6806af8b0337f1d405eef3931fb8a5e2b33e78e6/.wordpressorg/banner-772x250.png -------------------------------------------------------------------------------- /.wordpressorg/icon-128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevStudios/Automatic-Featured-Images-from-Videos/6806af8b0337f1d405eef3931fb8a5e2b33e78e6/.wordpressorg/icon-128x128.png -------------------------------------------------------------------------------- /.wordpressorg/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/WebDevStudios/Automatic-Featured-Images-from-Videos/6806af8b0337f1d405eef3931fb8a5e2b33e78e6/.wordpressorg/icon-256x256.png -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | = 1.2.5 = 2 | 3 | * Updated: added nonce verification to bulk processing cron scheduling request. 4 | 5 | = 1.2.4 = 6 | 7 | * Fixed: Better file naming of incoming images, based on youtube/video title value. 8 | * Confirmed compatibility with WordPress 6.6.x 9 | 10 | = 1.2.3 = 11 | 12 | * Confirmed compatibility with WordPress 6.5 13 | 14 | = 1.2.2 = 15 | 16 | * Confirmed compatibility with WordPress 6.4 17 | * Fixed: PHP notices around video url variables 18 | * Updated: removed `www.` from Vimeo endpoints that showed permanent redirect messages. 19 | 20 | = 1.2.1 = 21 | 22 | * Confirmed compatibility with WordPress 6.3 23 | 24 | = 1.2.0 = 25 | 26 | * Added: Support for potentially larger Vimeo images from API response. 27 | * Fixed: Various PHP notices and errors. 28 | * Updated: Minimum PHP version. 29 | * Updated: bumped up default string length to 4000 characters, for URL searching in content. 30 | * Updated: exclude user profile URLs from Youtube regex. 31 | * Updated: Switched all endpoints to make sure we're using HTTPS. 32 | * Updated: Vimeo endpoint switched to JSON responses. 33 | * Updated: Plugin description. 34 | 35 | = 1.1.2 = 36 | 37 | * Fixed: Issues with Youtube HEAD request returning 40x errors. 38 | 39 | = 1.1.1 = 40 | 41 | * Fixed: Extra forward slash in YouTube URLs that was causing 404 errors when trying to add to media library. 42 | 43 | = 1.1.0 = 44 | 45 | * Added: Metabox that displays the found video URL and embed URL. Values saved as post meta. 46 | * Added: Pass post ID for the `wds_check_if_content_contains_video` filter. 47 | * Added: Filters that allow customization by developers to alter default values. 48 | * Added: BETA: Bulk processing of posts for those missing thumbnails from videos. Please report issues found. 49 | * Added: BETA: WP-CLI support. 50 | * Fixed: Modified the way the vimeo embed URL is returned. 51 | * Fixed: Prevent multiple instances of same found image from being uploaded to media library. 52 | * 53 | = 1.0.5 = 54 | 55 | * Added function wds_get_video_url when passed a post_id returns a video URL. 56 | * Added function wds_get_embed_video_url when passed a post_id returns a video URL that is embeddable. 57 | * Added function wds_post_has_video to check if a post_id has video. 58 | * Deprecated wds_set_media_as_featured_image. 59 | * Refactored the default save_post entry function to handle logic better. 60 | 61 | = 1.0.4 = 62 | 63 | * Store the full video url in post meta _video_url. 64 | * Refactored checks for video ID. 65 | 66 | = 1.0.3 = 67 | 68 | * Switch to using WP HTTP API functions over get_headers(). Hopefully removes potential server config conflicts. 69 | * Reverse originally incorrect logic in YouTube thumbnail selection based on header results. 70 | * Return early if saving a revision. 71 | 72 | = 1.0.2 = 73 | 74 | * Add support for youtube short links, 75 | fixes [#3](https://github.com/WebDevStudios/Automatic-Featured-Images-from-Videos/issues/3) 76 | 77 | = 1.0.1 = 78 | 79 | * Fix bug with special characters in YouTube video titles 80 | * Fix bug where duplicate images would be uploaded and set as featured image when editing a post 81 | 82 | = 1.0 = 83 | 84 | * Initial release 85 | -------------------------------------------------------------------------------- /automatic-featured-images-from-videos.php: -------------------------------------------------------------------------------- 1 | post_content ) ? $post->post_content : ''; 102 | 103 | /** 104 | * Only check the first 800 characters of our post, by default. 105 | * 106 | * @since 1.0.0 107 | * 108 | * @param int $value Character limit to search. 109 | */ 110 | $content = substr( $content, 0, apply_filters( 'wds_featured_images_character_limit', 4000 ) ); 111 | 112 | // Allow developers to filter the content to allow for searching in postmeta or other places. 113 | $content = apply_filters( 'wds_featured_images_from_video_filter_content', $content, $post_id ); 114 | 115 | // Set the video id. 116 | $youtube_id = wds_check_for_youtube( $content ); 117 | $vimeo_id = wds_check_for_vimeo( $content ); 118 | $video_thumbnail_url = ''; 119 | $video_url = ''; 120 | $video_embed_url = ''; 121 | $video_title = ''; 122 | $youtube_details = []; 123 | $vimeo_details = []; 124 | 125 | if ( $youtube_id ) { 126 | $youtube_details = wds_get_youtube_details( $youtube_id ); 127 | if ( ! empty( $youtube_details ) ) { 128 | $video_thumbnail_url = $youtube_details['video_thumbnail_url']; 129 | $video_url = $youtube_details['video_url']; 130 | $video_embed_url = $youtube_details['video_embed_url']; 131 | $video_title = $youtube_details['video_title']; 132 | } 133 | } 134 | 135 | if ( $vimeo_id ) { 136 | $vimeo_details = wds_get_vimeo_details( $vimeo_id ); 137 | if ( ! empty( $vimeo_details ) ) { 138 | $video_thumbnail_url = $vimeo_details['video_thumbnail_url']; 139 | $video_url = $vimeo_details['video_url']; 140 | $video_embed_url = $vimeo_details['video_embed_url']; 141 | $video_title = $vimeo_details['video_title']; 142 | } 143 | } 144 | 145 | if ( $post_id 146 | && ! has_post_thumbnail( $post_id ) 147 | && $content 148 | && ( $youtube_details || $vimeo_details ) 149 | ) { 150 | $video_id = ''; 151 | if ( $youtube_id ) { 152 | $video_id = $youtube_id; 153 | } 154 | if ( $vimeo_id ) { 155 | $video_id = $vimeo_id; 156 | } 157 | if ( ! wp_is_post_revision( $post_id ) ) { 158 | wds_set_video_thumbnail_as_featured_image( $post_id, $video_thumbnail_url, $video_id, $video_title ); 159 | } 160 | } 161 | 162 | if ( $post_id 163 | && $content 164 | && ( $youtube_id || $vimeo_id ) 165 | ) { 166 | update_post_meta( $post_id, '_is_video', true ); 167 | update_post_meta( $post_id, '_video_url', $video_url ); 168 | update_post_meta( $post_id, '_video_embed_url', $video_embed_url ); 169 | } else { 170 | // Need to set because we don't have one, and we can skip on future iterations. 171 | // Need way to potentially force check ALL. 172 | update_post_meta( $post_id, '_is_video', false ); 173 | delete_post_meta( $post_id, '_video_url' ); 174 | delete_post_meta( $post_id, '_video_embed_url' ); 175 | } 176 | 177 | } 178 | 179 | /** 180 | * If a YouTube or Vimeo video is added in the post content, grab its thumbnail and set it as the featured image. 181 | * 182 | * @since 1.0.0 183 | * 184 | * @param int $post_id ID of the post being saved. 185 | * @param string $video_thumbnail_url URL of the image thumbnail. 186 | * @param string $video_id Video ID from embed. 187 | */ 188 | function wds_set_video_thumbnail_as_featured_image( $post_id, $video_thumbnail_url, $video_id = '', $video_title = '' ) { 189 | 190 | // Bail if no valid video thumbnail URL. 191 | if ( ! $video_thumbnail_url || is_wp_error( $video_thumbnail_url ) ) { 192 | return; 193 | } 194 | 195 | if ( ! empty( $video_title ) ) { 196 | $post_title = sanitize_title( $video_title ); 197 | } else { 198 | $post_title = sanitize_title( preg_replace( '/[^a-zA-Z0-9\s]/', '-', get_the_title( $post_id ) ) ) . '-' . $video_id; 199 | } 200 | 201 | global $wpdb; 202 | 203 | $stmt = "SELECT ID FROM {$wpdb->posts}"; 204 | $stmt .= $wpdb->prepare( 205 | ' WHERE post_type = %s AND guid LIKE %s', 206 | 'attachment', 207 | '%' . $wpdb->esc_like( $video_id ) . '%' 208 | ); 209 | $attachment = $wpdb->get_col( $stmt ); 210 | if ( !empty( $attachment[0] ) ) { 211 | $attachment_id = $attachment[0]; 212 | } else { 213 | // Try to sideload the image. 214 | $attachment_id = wds_ms_media_sideload_image_with_new_filename( $video_thumbnail_url, $post_id, $post_title, $video_id ); 215 | } 216 | 217 | // Bail if unable to sideload (happens if the URL or post ID is invalid, or if the URL 404s). 218 | if ( is_wp_error( $attachment_id ) ) { 219 | return; 220 | } 221 | 222 | // Woot! We got an image, so set it as the post thumbnail. 223 | set_post_thumbnail( $post_id, $attachment_id ); 224 | } 225 | 226 | /** 227 | * Check if the content contains a youtube url. 228 | * 229 | * Props to @rzen for lending his massive brain smarts to help with the regex. 230 | * 231 | * @author Gary Kovar 232 | * 233 | * @param $content 234 | * 235 | * @return string The value of the youtube id. 236 | * 237 | */ 238 | function wds_check_for_youtube( $content ) { 239 | if ( preg_match( '#\/\/(www\.)?(youtu|youtube|youtube-nocookie)\.(com|be)\/(?!.*user)(watch|embed)?\/?(\?v=)?([a-zA-Z0-9\-\_]+)#', $content, $youtube_matches ) ) { 240 | return $youtube_matches[6]; 241 | } 242 | 243 | return false; 244 | } 245 | 246 | /** 247 | * Check if the content contains a vimeo url. 248 | * 249 | * Props to @rzen for lending his massive brain smarts to help with the regex. 250 | * 251 | * @author Gary Kovar 252 | * 253 | * @param $content 254 | * 255 | * @return string The value of the vimeo id. 256 | * 257 | */ 258 | function wds_check_for_vimeo( $content ) { 259 | if ( preg_match( '#\/\/(.+\.)?(vimeo\.com)\/(\d*)#', $content, $vimeo_matches ) ) { 260 | return $vimeo_matches[3]; 261 | } 262 | 263 | return false; 264 | } 265 | 266 | /** 267 | * Handle the upload of a new image. 268 | * 269 | * @since 1.0.0 270 | * 271 | * @param string $url URL to sideload. 272 | * @param int $post_id Post ID to attach to. 273 | * @param string|null $filename Filename to use. 274 | * @param string $video_id Video ID. 275 | * 276 | * @return mixed 277 | */ 278 | function wds_ms_media_sideload_image_with_new_filename( $url, $post_id, $filename = null, $video_id = null ) { 279 | 280 | if ( ! $url || ! $post_id ) { 281 | return new WP_Error( 'missing', esc_html__( 'Need a valid URL and post ID...', 'automatic-featured-images-from-videos' ) ); 282 | } 283 | 284 | require_once( ABSPATH . 'wp-admin/includes/file.php' ); 285 | 286 | // Download file to temp location, returns full server path to temp file, ex; /home/user/public_html/mysite/wp-content/26192277_640.tmp. 287 | $tmp = download_url( $url ); 288 | 289 | // If error storing temporarily, unlink. 290 | if ( is_wp_error( $tmp ) ) { 291 | // And output wp_error. 292 | return $tmp; 293 | } 294 | 295 | // Fix file filename for query strings. 296 | preg_match( '/[^\?]+\.(jpg|JPG|jpe|JPE|jpeg|JPEG|gif|GIF|png|PNG)/', $url, $matches ); 297 | // Extract filename from url for title. 298 | $url_filename = basename( $matches[0] ); 299 | // Determine file type (ext and mime/type). 300 | $url_type = wp_check_filetype( $url_filename ); 301 | 302 | // Override filename if given, reconstruct server path. 303 | if ( ! empty( $filename ) ) { 304 | $filename = sanitize_file_name( $filename ); 305 | // Extract path parts. 306 | $tmppath = pathinfo( $tmp ); 307 | // Build new path. 308 | $new = $tmppath['dirname'] . '/' . $filename . '.' . $tmppath['extension']; 309 | // Renames temp file on server. 310 | rename( $tmp, $new ); 311 | // Push new filename (in path) to be used in file array later. 312 | $tmp = $new; 313 | } 314 | 315 | /* Assemble file data (should be built like $_FILES since wp_handle_sideload() will be using). */ 316 | 317 | // Full server path to temp file. 318 | $file_array['tmp_name'] = $tmp; 319 | 320 | if ( ! empty( $filename ) ) { 321 | // User given filename for title, add original URL extension. 322 | $file_array['name'] = $filename . '.' . $url_type['ext']; 323 | } else { 324 | // Just use original URL filename. 325 | $file_array['name'] = $url_filename; 326 | } 327 | 328 | $post_data = [ 329 | // Just use the original filename (no extension). 330 | 'post_title' => get_the_title( $post_id ), 331 | // Make sure gets tied to parent. 332 | 'post_parent' => $post_id, 333 | ]; 334 | 335 | // Required libraries for media_handle_sideload. 336 | require_once( ABSPATH . 'wp-admin/includes/file.php' ); 337 | require_once( ABSPATH . 'wp-admin/includes/media.php' ); 338 | require_once( ABSPATH . 'wp-admin/includes/image.php' ); 339 | 340 | // Do the validation and storage stuff. 341 | // $post_data can override the items saved to wp_posts table, like post_mime_type, guid, post_parent, post_title, post_content, post_status. 342 | $att_id = media_handle_sideload( $file_array, $post_id, null, $post_data ); 343 | 344 | // If error storing permanently, unlink. 345 | if ( is_wp_error( $att_id ) ) { 346 | // Clean up. 347 | @unlink( $file_array['tmp_name'] ); 348 | 349 | // And output wp_error. 350 | return $att_id; 351 | } 352 | 353 | return $att_id; 354 | } 355 | 356 | /** 357 | * Get the image thumbnail and the video url from a youtube id. 358 | * 359 | * @author Gary Kovar 360 | * 361 | * @since 1.0.5 362 | * 363 | * @param string $youtube_id Youtube video ID. 364 | * @return array Video data. 365 | */ 366 | function wds_get_youtube_details( $youtube_id ) { 367 | $video = []; 368 | $video_thumbnail_url_string = 'https://img.youtube.com/vi/%s/%s'; 369 | 370 | $video_check = wp_remote_get( 'https://www.youtube.com/oembed?format=json&url=https://www.youtube.com/watch?v=' . $youtube_id ); 371 | if ( 200 === wp_remote_retrieve_response_code( $video_check ) ) { 372 | $remote_headers = wp_remote_head( 373 | sprintf( 374 | $video_thumbnail_url_string, 375 | $youtube_id, 376 | 'maxresdefault.jpg' 377 | ) 378 | ); 379 | $video['video_thumbnail_url'] = ( 404 === wp_remote_retrieve_response_code( $remote_headers ) ) ? 380 | sprintf( 381 | $video_thumbnail_url_string, 382 | $youtube_id, 383 | 'hqdefault.jpg' 384 | ) : 385 | sprintf( 386 | $video_thumbnail_url_string, 387 | $youtube_id, 388 | 'maxresdefault.jpg' 389 | ); 390 | $video['video_url'] = 'https://www.youtube.com/watch?v=' . $youtube_id; 391 | $video['video_embed_url'] = 'https://www.youtube.com/embed/' . $youtube_id; 392 | 393 | $video_data = json_decode( wp_remote_retrieve_body( $video_check) ); 394 | $video['video_title'] = $video_data->title; 395 | } 396 | 397 | return $video; 398 | } 399 | 400 | /** 401 | * Get the image thumbnail and the video url from a vimeo id. 402 | * 403 | * @author Gary Kovar 404 | * 405 | * @since 1.0.5 406 | * 407 | * @param string $vimeo_id Vimeo video ID. 408 | * @return array Video information. 409 | */ 410 | function wds_get_vimeo_details( $vimeo_id ) { 411 | $video = []; 412 | 413 | // @todo Get remote checking matching with wds_get_youtube_details. 414 | $vimeo_data = wp_remote_get( 'https://vimeo.com/api/v2/video/' . intval( $vimeo_id ) . '.json' ); 415 | if ( 200 === wp_remote_retrieve_response_code( $vimeo_data ) ) { 416 | $response = json_decode( $vimeo_data['body'] ); 417 | 418 | $large = isset( $response[0]->thumbnail_large ) ? $response[0]->thumbnail_large : ''; 419 | if ( $large ) { 420 | $larger_test = explode( '_', $large ); 421 | $test_result = wp_remote_head( 422 | $larger_test[0] 423 | ); 424 | if ( 200 === wp_remote_retrieve_response_code( $test_result ) ) { 425 | $large = $larger_test[0]; 426 | } 427 | } 428 | 429 | // For the moment, we will force jpg since WebP is still iffy. 430 | $video['video_thumbnail_url'] = isset( $large ) ? $large . '.jpg' : false; 431 | $video['video_url'] = $response[0]->url; 432 | $video['video_embed_url'] = 'https://player.vimeo.com/video/' . $vimeo_id; 433 | $video['video_title'] = $response[0]->title; 434 | } 435 | 436 | return $video; 437 | } 438 | 439 | /** 440 | * Check if the post is a video. 441 | * 442 | * @author Gary Kovar 443 | * 444 | * @since 1.0.5 445 | * 446 | * @param int $post_id WP post ID to check for video on. 447 | * @return bool 448 | */ 449 | function wds_post_has_video( $post_id ) { 450 | if ( ! metadata_exists( 'post', $post_id, '_is_video' ) ) { 451 | wds_check_if_content_contains_video( $post_id, get_post( $post_id ) ); 452 | } 453 | 454 | return get_post_meta( $post_id, '_is_video', true ); 455 | } 456 | 457 | /** 458 | * Get the URL for the video. 459 | * 460 | * @author Gary Kovar 461 | * 462 | * @since 1.0.5 463 | * 464 | * @param int $post_id Post ID to get video url for. 465 | * @return string 466 | */ 467 | function wds_get_video_url( $post_id ) { 468 | if ( wds_post_has_video( $post_id ) ) { 469 | if ( ! metadata_exists( 'post', $post_id, '_video_url' ) ) { 470 | wds_check_if_content_contains_video( $post_id, get_post( $post_id ) ); 471 | } 472 | 473 | return get_post_meta( $post_id, '_video_url', true ); 474 | } 475 | return ''; 476 | } 477 | 478 | /** 479 | * Get the embeddable URL 480 | * 481 | * @author Gary Kovar 482 | * 483 | * @since 1.0.5 484 | * 485 | * @param int $post_id Post ID to grab video for. 486 | * @return string 487 | */ 488 | function wds_get_embeddable_video_url( $post_id ) { 489 | if ( wds_post_has_video( $post_id ) ) { 490 | if ( ! metadata_exists( 'post', $post_id, '_video_embed_url' ) ) { 491 | wds_check_if_content_contains_video( $post_id, get_post( $post_id ) ); 492 | } 493 | 494 | return get_post_meta( $post_id, '_video_embed_url', true ); 495 | } 496 | return ''; 497 | } 498 | 499 | /** 500 | * Register a metabox to display the video on post edit view. 501 | * @author Gary Kovar 502 | * @since 1.1.0 503 | */ 504 | function wds_register_display_video_metabox( $post_type, $post ) { 505 | if ( get_post_meta( $post->ID, '_is_video', true ) ) { 506 | add_meta_box( 507 | 'wds_display_video_urls_metabox', 508 | esc_html__( 'Video Files found in Content', 'wds-automatic-featured-images-from-video' ), 509 | 'wds_video_thumbnail_meta' 510 | ); 511 | } 512 | } 513 | 514 | /** 515 | * Populate the metabox. 516 | * @author Gary Kovar 517 | * @since 1.1.0 518 | */ 519 | function wds_video_thumbnail_meta() { 520 | global $post; 521 | 522 | echo '