PluginProbe
Automatic YouTube Gallery – Embed Auto-Updating YouTube Video Galleries, Feeds, Playlists & Channels / trunk
Automatic YouTube Gallery – Embed Auto-Updating YouTube Video Galleries, Feeds, Playlists & Channels vtrunk
2.9.1 2.9.0 trunk 1.0.0 1.1.0 1.2.0 1.3.0 1.4.0 1.5.0 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.6.5 2.0.0 2.1.0 2.2.0 2.3.2 2.3.3 2.3.5 2.3.6 2.3.8 2.3.9 2.4.3 All 37 releases
automatic-youtube-gallery / includes / youtube-api.php

youtube-api.php in Automatic YouTube Gallery – Embed Auto-Updating YouTube Video Galleries, Feeds, Playlists & Channels trunk, at includes/youtube-api.php

1,228 lines 36.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * A wrapper class for the Youtube Data API v3.
5 *
6 * @link https://plugins360.com
7 * @since 1.0.0
8 *
9 * @package Automatic_YouTube_Gallery
10 */
11
12 // Exit if accessed directly
13 if ( ! defined( 'WPINC' ) ) {
14 die;
15 }
16
17 /**
18 * AYG_YouTube_API class.
19 *
20 * @since 1.0.0
21 */
22 class AYG_YouTube_API {
23
24 /**
25 * The YouTube API Key.
26 *
27 * @since 1.0.0
28 * @access protected
29 * @var string
30 */
31 protected $api_key;
32
33 /**
34 * Array of query params.
35 *
36 * @since 2.5.7
37 * @access protected
38 * @var array
39 */
40 protected $params = array();
41
42 /**
43 * May this query write gallery membership rows?
44 *
45 * Opt-in, set from the "store" query param. Only trusted server side callers set it — the
46 * page renderer (ayg_build_gallery), the Gallery Builder importer, and the public AJAX
47 * endpoint once it has resolved the gallery itself. See request_api().
48 *
49 * @since 2.9.0
50 * @access protected
51 * @var bool
52 */
53 protected $can_store = false;
54
55 /**
56 * Is development mode enabled?
57 *
58 * @since 2.3.0
59 * @access protected
60 * @var bool
61 */
62 protected $is_development_mode = false;
63
64 /**
65 * The YouTube API URLs.
66 *
67 * @since 1.0.0
68 * @access protected
69 * @var array
70 */
71 public $api_urls = array(
72 'playlistItems.list' => 'https://www.googleapis.com/youtube/v3/playlistItems',
73 'channels.list' => 'https://www.googleapis.com/youtube/v3/channels',
74 'search.list' => 'https://www.googleapis.com/youtube/v3/search',
75 'videos.list' => 'https://www.googleapis.com/youtube/v3/videos'
76 );
77
78 /**
79 * Get videos.
80 *
81 * Side effect: successful responses are persisted to the custom tables via
82 * ayg_db_store_videos() (called inside request_api()) — video rows always, and
83 * gallery relationships only when the caller passes both a "uid" and "store" => true.
84 * This is how both legacy galleries and the Gallery Builder importer store their videos.
85 *
86 * "store" must never be set from user input: it decides which gallery the fetched videos
87 * are shown in. Callers reachable by unauthenticated visitors have to resolve the gallery
88 * server side first — see AYG_Public::ajax_callback_load_videos().
89 *
90 * @since 1.0.0
91 * @param array $params Array of query params.
92 * @return mixed
93 */
94 public function query( $params = array() ) {
95 // Get YouTube API Key
96 $general_settings = ayg_get_option( 'ayg_general_settings' );
97
98 // DB-served responses (gallery search + the internal "db" source type) read from the
99 // custom tables, so they're handled before the API-key guard — no key required.
100 if ( ! empty( $params['searchTerm'] ) || ( isset( $params['type'] ) && 'db' === $params['type'] ) ) {
101 return $this->get_videos_from_db( $params );
102 }
103
104 if ( empty( $general_settings['api_key'] ) ) {
105 return $this->get_error( __( 'YouTube API key not found.', 'automatic-youtube-gallery' ) . ' ' . sprintf( __( 'Kindly follow this URL <a href="%s" target="_blank" rel="noopener noreferrer">this guide</a> to get your own API key.', 'automatic-youtube-gallery' ), 'https://plugins360.com/automatic-youtube-gallery/how-to-get-youtube-api-key/' ) );
106 }
107
108 $this->api_key = $general_settings['api_key'];
109 $this->params = $params;
110 $this->can_store = ! empty( $params['store'] );
111
112 // Is development mode enabled?
113 if ( isset( $general_settings['development_mode'] ) && ! empty( $general_settings['development_mode'] ) ) {
114 $this->is_development_mode = true;
115 }
116
117 // Advanced mode fetches duration + live broadcast details via a supplementary
118 // videos.list call (used by the Gallery Builder importer). Defaults to off so
119 // legacy galleries make no extra API request.
120 $mode = isset( $params['mode'] ) ? $params['mode'] : 'basic';
121
122 // Process output
123 $response = array();
124
125 switch ( $params['type'] ) {
126 case 'playlist':
127 if ( empty( $params['src'] ) ) {
128 return $this->get_error( __( 'A YouTube playlist ID (or) URL is required.', 'automatic-youtube-gallery' ) );
129 }
130
131 $response = $this->request_api_playlist_items( $params );
132 break;
133
134 case 'channel':
135 if ( empty( $params['src'] ) ) {
136 return $this->get_error( __( 'A YouTube channel ID (or) a video URL from the channel is required.', 'automatic-youtube-gallery' ) );
137 }
138
139 // @handle URLs can't be resolved to a channel ID here (mirrors the client-side
140 // check in admin.js / gallery-form.php).
141 if ( false !== strpos( $params['src'], '@' ) ) {
142 return $this->get_error( __( 'YouTube @handle URLs aren’t supported here. Please enter a channel ID, a /channel/ URL, or a video URL from the channel.', 'automatic-youtube-gallery' ) );
143 }
144
145 $params['id'] = $this->get_channel_id( $params );
146
147 if ( empty( $params['id'] ) ) {
148 return $this->get_error( __( 'Invalid YouTube channel ID.', 'automatic-youtube-gallery' ) );
149 }
150
151 // Get playlist id from the channel
152 $playlist_id = $this->get_playlist_id( $params );
153
154 if ( is_object( $playlist_id ) && isset( $playlist_id->error ) ) {
155 return $playlist_id;
156 }
157
158 if ( empty( $playlist_id ) ) {
159 return $this->get_error( __( 'No videos found matching your query.', 'automatic-youtube-gallery' ) );
160 }
161
162 // Get videos using the playlist id
163 $params['src'] = $playlist_id;
164 $response = $this->request_api_playlist_items( $params );
165 break;
166
167 case 'username':
168 if ( empty( $params['src'] ) ) {
169 return $this->get_error( __( 'A YouTube account username is required.', 'automatic-youtube-gallery' ) );
170 }
171
172 // Get playlist id from the channel
173 $params['forUsername'] = $this->parse_youtube_id_from_url( $params['src'], 'username' );
174 $playlist_id = $this->get_playlist_id( $params );
175
176 if ( is_object( $playlist_id ) && isset( $playlist_id->error ) ) {
177 return $playlist_id;
178 }
179
180 if ( empty( $playlist_id ) ) {
181 return $this->get_error( __( 'No videos found matching your query.', 'automatic-youtube-gallery' ) );
182 }
183
184 // Get videos using the playlist id
185 $params['src'] = $playlist_id;
186 $response = $this->request_api_playlist_items( $params );
187 break;
188
189 case 'search':
190 if ( empty( $params['src'] ) ) {
191 return $this->get_error( __( 'A search keyword is required.', 'automatic-youtube-gallery' ) );
192 }
193
194 $response = $this->request_api_search( $params );
195 break;
196
197 case 'videos':
198 if ( empty( $params['src'] ) ) {
199 return $this->get_error( __( 'At least one YouTube video ID (or) URL is required.', 'automatic-youtube-gallery' ) );
200 }
201
202 $response = $this->request_api_videos( $params );
203 break;
204
205 case 'livestream':
206 if ( empty( $params['src'] ) ) {
207 return $this->get_error( __( 'A YouTube channel ID (or) a video URL from the channel is required.', 'automatic-youtube-gallery' ) );
208 }
209
210 // @handle URLs can't be resolved to a channel ID here (mirrors the client-side
211 // check in admin.js / gallery-form.php).
212 if ( false !== strpos( $params['src'], '@' ) ) {
213 return $this->get_error( __( 'YouTube @handle URLs aren’t supported here. Please enter a channel ID, a /channel/ URL, or a video URL from the channel.', 'automatic-youtube-gallery' ) );
214 }
215
216 $params['channelId'] = $this->get_channel_id( $params );
217
218 if ( empty( $params['channelId'] ) ) {
219 return $this->get_error( __( 'Invalid YouTube channel ID.', 'automatic-youtube-gallery' ) );
220 }
221
222 // Get live video using the channel id
223 $response = $this->request_api_live_video( $params );
224 break;
225
226 default: // video
227 if ( empty( $params['src'] ) ) {
228 return $this->get_error( __( 'A YouTube video ID (or) URL is required.', 'automatic-youtube-gallery' ) );
229 }
230
231 $response = $this->request_api_video( $params );
232 break;
233 }
234
235 // Advanced mode: enrich channel/playlist/username/search videos (which come from
236 // playlistItems.list / search.list and lack duration + live broadcast details).
237 if ( 'advanced' === $mode && ! isset( $response->error ) && ! empty( $response->videos ) && in_array( $params['type'], array( 'playlist', 'channel', 'username', 'search' ), true ) ) {
238 $response->videos = $this->enrich_video_details( $response->videos );
239 }
240
241 return $response;
242 }
243
244 /**
245 * Grab the playlist, channel or video ID using the YouTube URL given.
246 *
247 * @since 1.0.0
248 * @access private
249 * @param string $url YouTube URL.
250 * @param string $type Type of the URL (playlist|channel|video).
251 * @return mixed
252 */
253 private function parse_youtube_id_from_url( $url, $type = 'video' ) {
254 $url = trim( $url );
255 $id = $url;
256
257 switch ( $type ) {
258 case 'playlist':
259 if ( preg_match( '/[?&]list=([^&]+)/', $url, $matches ) ) {
260 $id = $matches[1];
261 }
262 break;
263
264 case 'channel':
265 if ( wp_http_validate_url( $id ) ) {
266 $id = '';
267 }
268
269 $url = parse_url( rtrim( $url, '/' ) );
270
271 if ( isset( $url['path'] ) && preg_match( '/^\/channel\/(([^\/])+?)$/', $url['path'], $matches ) ) {
272 $id = $matches[1];
273 }
274 break;
275
276 case 'username':
277 $url = parse_url( rtrim( $url, '/' ) );
278
279 if ( isset( $url['path'] ) && preg_match( '/^\/user\/(([^\/])+?)$/', $url['path'], $matches ) ) {
280 $id = $matches[1];
281 }
282 break;
283
284 default: // video
285 if ( wp_http_validate_url( $id ) ) {
286 $id = '';
287 }
288
289 $url = parse_url( $url );
290
291 if ( array_key_exists( 'host', $url ) ) {
292 if ( 0 === strcasecmp( $url['host'], 'youtu.be' ) ) {
293 $id = substr( $url['path'], 1 );
294 } elseif ( 0 === strcasecmp( $url['host'], 'www.youtube.com' ) || 0 === strcasecmp( $url['host'], 'youtube.com' ) ) {
295 if ( isset( $url['query'] ) ) {
296 parse_str( $url['query'], $url['query'] );
297
298 if ( isset( $url['query']['v'] ) ) {
299 $id = $url['query']['v'];
300 }
301 }
302
303 if ( empty( $id ) ) {
304 $url['path'] = explode( '/', substr( $url['path'], 1 ) );
305 if ( in_array( $url['path'][0], array( 'e', 'embed', 'v', 'shorts', 'live' ) ) ) {
306 $id = $url['path'][1];
307 }
308 }
309 }
310 }
311 }
312
313 return $id;
314 }
315
316 /**
317 * Get the channel ID.
318 *
319 * @since 2.0.0
320 * @access private
321 * @param array $params Array of query params.
322 * @return string
323 */
324 private function get_channel_id( $params = array() ) {
325 // Parse channel ID from URL: https://www.youtube.com/channel/XXXXXXXXXX
326 $id = $this->parse_youtube_id_from_url( $params['src'], 'channel' );
327
328 if ( empty( $id ) ) {
329 // Get channel ID from a Video URL: https://www.youtube.com/watch?v=XXXXXXXXXX
330 $video_id = $this->parse_youtube_id_from_url( $params['src'], 'video' );
331
332 // Request from cache
333 $channel_ids = ayg_get_option( 'ayg_channel_ids' );
334
335 if ( isset( $channel_ids[ $video_id ] ) && ! empty( $channel_ids[ $video_id ] ) ) {
336 return $channel_ids[ $video_id ];
337 }
338
339 // Request from API
340 $api_url = $this->get_api_url( 'videos.list' );
341
342 $params['id'] = $video_id;
343
344 $api_params = $this->safe_merge_params(
345 array(
346 'id' => '',
347 'part' => 'id,snippet,contentDetails,status',
348 'cache' => 0
349 ),
350 $params
351 );
352
353 $api_response = $this->request_api( $api_url, $api_params, 'channel_id' );
354 if ( isset( $api_response->error ) ) {
355 return $id;
356 }
357
358 $videos = $this->parse_videos( $api_response );
359 if ( isset( $videos->error ) ) {
360 return $id;
361 }
362
363 // Process output
364 if ( $id = $videos[0]->channel_id ) {
365 // Store in cache
366 $channel_ids[ $video_id ] = $id;
367 update_option( 'ayg_channel_ids', $channel_ids, false );
368 }
369 }
370
371 return $id;
372 }
373
374 /**
375 * Get playlist id using channels API.
376 *
377 * @since 1.0.0
378 * @access private
379 * @param array $params Array of query params.
380 * @return mixed
381 */
382 private function get_playlist_id( $params = array() ) {
383 // Request from cache
384 $playlist_ids = ayg_get_option( 'ayg_playlist_ids' );
385
386 $key = '';
387
388 if ( isset( $params['forUsername'] ) && ! empty( $params['forUsername'] ) ) {
389 $key = $params['forUsername'];
390 }
391
392 if ( isset( $params['id'] ) && ! empty( $params['id'] ) ) {
393 unset( $params['forUsername'] );
394 $key = $params['id'];
395 }
396
397 if ( isset( $playlist_ids[ $key ] ) && ! empty( $playlist_ids[ $key ] ) ) {
398 return $playlist_ids[ $key ];
399 }
400
401 // Request from API
402 $api_url = $this->get_api_url( 'channels.list' );
403
404 $api_params = $this->safe_merge_params(
405 array(
406 'id' => '',
407 'forUsername' => '',
408 'part' => 'contentDetails',
409 'cache' => 0
410 ),
411 $params
412 );
413
414 $api_response = $this->request_api( $api_url, $api_params, 'playlist_id' );
415 if ( isset( $api_response->error ) ) {
416 return $api_response;
417 }
418
419 if ( ! isset( $api_response->items ) ) {
420 return false;
421 }
422
423 $items = $api_response->items;
424 if ( ! is_array( $items ) || count( $items ) == 0 ) {
425 return false;
426 }
427
428 // Process output
429 if ( $id = $items[0]->contentDetails->relatedPlaylists->uploads ) {
430 // Store in cache
431 $playlist_ids[ $key ] = $id;
432 update_option( 'ayg_playlist_ids', $playlist_ids, false );
433
434 // Return
435 return $id;
436 }
437
438 return false;
439 }
440
441 /**
442 * Get videos using playlistItems API.
443 *
444 * @since 1.0.0
445 * @access private
446 * @param array $params Array of query params.
447 * @return stdClass
448 */
449 private function request_api_playlist_items( $params = array() ) {
450 $api_url = $this->get_api_url( 'playlistItems.list' );
451
452 $params['playlistId'] = $this->parse_youtube_id_from_url( $params['src'], 'playlist' );
453
454 $api_params = $this->safe_merge_params(
455 array(
456 'playlistId' => '',
457 'part' => 'id,snippet,contentDetails,status',
458 'maxResults' => 50,
459 'pageToken' => '',
460 'cache' => 0
461 ),
462 $params
463 );
464
465 $api_response = $this->request_api( $api_url, $api_params );
466 if ( isset( $api_response->error ) ) {
467 return $api_response;
468 }
469
470 $videos = $this->parse_videos( $api_response );
471 if ( isset( $videos->error ) ) {
472 return $videos;
473 }
474
475 // Process output
476 $response = new stdClass();
477 $response->page_info = $this->parse_page_info( $api_response );
478 $response->videos = $videos;
479
480 return $response;
481 }
482
483 /**
484 * Get videos using search API.
485 *
486 * @since 1.0.0
487 * @access private
488 * @param array $params Array of query params.
489 * @return stdClass
490 */
491 private function request_api_search( $params = array() ) {
492 $api_url = $this->get_api_url( 'search.list' );
493
494 // Passed through unmodified: request_api() now runs every parameter through
495 // http_build_query(), which encodes the OR operator "|" to %7C on its own. Pre-encoding it
496 // here would be double encoded into %257C and break the search.
497 $params['q'] = $params['src'];
498
499 $params['type'] = 'video'; // Overrides user defined type value 'search'
500
501 $api_params = $this->safe_merge_params(
502 array(
503 'q' => '',
504 'channelId' => '',
505 'type' => 'video',
506 'videoEmbeddable' => true,
507 'part' => 'id,snippet',
508 'order' => 'date',
509 'publishedAfter' => '', // Set by incremental sync to fetch only newly published videos
510 'maxResults' => 50,
511 'pageToken' => '',
512 'cache' => 0
513 ),
514 $params
515 );
516
517 $api_response = $this->request_api( $api_url, $api_params );
518 if ( isset( $api_response->error ) ) {
519 return $api_response;
520 }
521
522 $videos = $this->parse_videos( $api_response );
523 if ( isset( $videos->error ) ) {
524 return $videos;
525 }
526
527 // Process output
528 $response = new stdClass();
529 $response->page_info = $this->parse_page_info( $api_response );
530 $response->videos = $videos;
531
532 return $response;
533 }
534
535 /**
536 * Get live video using search API.
537 *
538 * @since 2.3.7
539 * @access private
540 * @param array $params Array of query params.
541 * @return mixed
542 */
543 private function request_api_live_video( $params = array() ) {
544 $api_url = $this->get_api_url( 'search.list' );
545
546 $params['type'] = 'video'; // Overrides user defined type value 'livestream'
547
548 $api_params = $this->safe_merge_params(
549 array(
550 'type' => 'video',
551 'eventType' => 'live',
552 'part' => 'snippet',
553 'channelId' => '',
554 'cache' => 0
555 ),
556 $params
557 );
558
559 $api_response = $this->request_api( $api_url, $api_params, 'live' );
560 if ( isset( $api_response->error ) ) {
561 return $api_response;
562 }
563
564 $videos = $this->parse_videos( $api_response );
565 if ( isset( $videos->error ) ) {
566 $livestream_settings = ayg_get_option( 'ayg_livestream_settings' );
567 return $this->get_error( '<div class="ayg-livestream-fallback-message">' . $livestream_settings['fallback_message'] . '</div>' );
568 }
569
570 // Process output
571 $response = new stdClass();
572 $response->videos = $videos;
573
574 return $response;
575 }
576
577 /**
578 * Get details of the given video ID.
579 *
580 * @since 1.0.0
581 * @access private
582 * @param array $params Array of query params.
583 * @return stdClass
584 */
585 private function request_api_video( $params = array() ) {
586 $api_url = $this->get_api_url( 'videos.list' );
587
588 $params['id'] = $this->parse_youtube_id_from_url( $params['src'], 'video' );
589
590 $api_params = $this->safe_merge_params(
591 array(
592 'id' => '',
593 'part' => 'id,snippet,contentDetails,status',
594 'cache' => 0
595 ),
596 $params
597 );
598
599 $api_response = $this->request_api( $api_url, $api_params );
600 if ( isset( $api_response->error ) ) {
601 return $api_response;
602 }
603
604 $videos = $this->parse_videos( $api_response );
605 if ( isset( $videos->error ) ) {
606 return $videos;
607 }
608
609 // Process output
610 $response = new stdClass();
611 $response->videos = $videos;
612
613 return $response;
614 }
615
616 /**
617 * Get details of the given video IDs.
618 *
619 * @since 1.0.0
620 * @access private
621 * @param array $params Array of query params.
622 * @return stdClass
623 */
624 private function request_api_videos( $params = array() ) {
625 $api_url = $this->get_api_url( 'videos.list' );
626
627 // Accept the video list separated by commas, spaces, or newlines (one per line) in any
628 // combination and line-ending style. The old "\n\r" replace looked for LF+CR (reversed),
629 // so a one-per-line list never split — it collapsed into a single invalid ID and returned
630 // "No videos found". Split on any run of whitespace or commas and drop empties instead.
631 $urls = preg_split( '/[\s,]+/', trim( (string) $params['src'] ), -1, PREG_SPLIT_NO_EMPTY );
632 $urls = is_array( $urls ) ? $urls : array();
633
634 $all_ids = array();
635 foreach ( $urls as $url ) {
636 $all_ids[] = $this->parse_youtube_id_from_url( $url, 'video' );
637 }
638 $total_videos = count( $all_ids );
639 $total_pages = ceil( $total_videos / $params['maxResults'] );
640
641 $current_page = isset( $params['pageToken'] ) ? (int) $params['pageToken'] : 1;
642 $current_page = max( $current_page, 1 );
643 $current_page = min( $current_page, $total_pages );
644
645 $offset = max( 0, ( $current_page - 1 ) * $params['maxResults'] );
646
647 $current_ids = array_slice( $all_ids, $offset, $params['maxResults'] );
648 $params['id'] = implode( ',', $current_ids );
649
650 $api_params = $this->safe_merge_params(
651 array(
652 'id' => '',
653 'part' => 'id,snippet,contentDetails,status',
654 'cache' => 0
655 ),
656 $params
657 );
658
659 $api_response = $this->request_api( $api_url, $api_params );
660 if ( isset( $api_response->error ) ) {
661 return $api_response;
662 }
663
664 $videos = $this->parse_videos( $api_response );
665 if ( isset( $videos->error ) ) {
666 return $videos;
667 }
668
669 // Process output
670 $response = new stdClass();
671 $response->videos = $videos;
672
673 $response->page_info = array(
674 'videos_found' => $total_videos,
675 'total_pages' => $total_pages,
676 'paged' => $current_page
677 );
678
679 if ( $current_page > 1 ) {
680 $response->page_info['prev_page_token'] = $current_page - 1;
681 }
682
683 if ( $current_page < $total_pages ) {
684 $response->page_info['next_page_token'] = $current_page + 1;
685 }
686
687 return $response;
688 }
689
690 /**
691 * Get videos from our custom database table "{$wpdb->prefix}ayg_videos".
692 *
693 * @since 2.5.7
694 * @access private
695 * @param array $params Array of query params.
696 * @return stdClass
697 */
698 private function get_videos_from_db( $params = array() ) {
699 global $wpdb;
700
701 $videos_table = $wpdb->prefix . 'ayg_videos';
702 $rel_table = $wpdb->prefix . 'ayg_gallery_relationships';
703
704 $gallery_id = $params['uid'];
705
706 // Base query: every video linked to this gallery. An optional search term (the search
707 // form) narrows it by title/description; the "db" source type passes none and gets all.
708 $where = 'r.gallery_id = %s';
709 $values = array( $gallery_id );
710
711 if ( ! empty( $params['searchTerm'] ) ) {
712 $search_term = '%' . $wpdb->esc_like( $params['searchTerm'] ) . '%';
713
714 $where .= ' AND (v.title LIKE %s OR v.description LIKE %s)';
715 $values[] = $search_term;
716 $values[] = $search_term;
717 }
718
719 // Optional duration filter. Whitelisted operator; value parameterized. Affects count + select.
720 $duration_filter = isset( $params['duration_filter'] ) ? $params['duration_filter'] : '';
721 $duration = isset( $params['duration'] ) ? (int) $params['duration'] : 0;
722
723 if ( $duration > 0 && in_array( $duration_filter, array( 'long', 'short' ), true ) ) {
724 $where .= ( 'long' === $duration_filter ) ? ' AND v.duration_seconds > %d' : ' AND v.duration_seconds < %d';
725 $values[] = $duration;
726 }
727
728 // Get Total Videos Count
729 $total_videos = $wpdb->get_var(
730 $wpdb->prepare(
731 "SELECT COUNT(*)
732 FROM $videos_table AS v
733 INNER JOIN $rel_table AS r ON v.video_id = r.video_id
734 WHERE $where",
735 $values
736 )
737 );
738
739 if ( empty( $total_videos ) ) {
740 return $this->get_error( __( 'No videos found matching your query.', 'automatic-youtube-gallery' ) );
741 }
742
743 // Fetch Paginated Videos
744 $limit = (int) $params['maxResults'];
745
746 if ( $limit <= 0 ) {
747 // 0 = show all videos on a single page (Gallery Builder "unlimited" per page).
748 // A LIMIT of the total count avoids the empty result set that LIMIT 0 would return.
749 $limit = (int) $total_videos;
750 $total_pages = 1;
751 $current_page = 1;
752 $offset = 0;
753 } else {
754 $total_pages = ceil( $total_videos / $limit );
755
756 $current_page = isset( $params['pageToken'] ) ? (int) $params['pageToken'] : 1;
757 $current_page = max( $current_page, 1 );
758 $current_page = min( $current_page, $total_pages );
759
760 $offset = max( 0, ( $current_page - 1 ) * $limit );
761 }
762
763 // Display-time ordering. Whitelisted (ORDER BY can't be parameterized); v.id breaks ties.
764 $orderby_map = array(
765 'date' => 'v.published_at_datetime',
766 'title' => 'v.title',
767 'duration' => 'v.duration_seconds'
768 );
769
770 $sort_by_raw = isset( $params['sort_by'] ) ? $params['sort_by'] : 'date';
771 $sort_order = ( isset( $params['sort_order'] ) && 'asc' === strtolower( $params['sort_order'] ) ) ? 'ASC' : 'DESC';
772
773 // Random: a seed (set per render in ayg_build_gallery) makes RAND(seed) stable across this
774 // gallery's own pagination/search, so pages don't repeat or skip videos.
775 $random_seed = 0;
776
777 if ( 'random' === $sort_by_raw ) {
778 $random_seed = isset( $params['sort_seed'] ) ? (int) $params['sort_seed'] : 0;
779 $order_by = ( $random_seed > 0 ) ? 'RAND(%d)' : 'RAND()';
780 } else {
781 $sort_by = isset( $orderby_map[ $sort_by_raw ] ) ? $orderby_map[ $sort_by_raw ] : 'v.published_at_datetime';
782 $order_by = "$sort_by $sort_order, v.id $sort_order";
783 }
784
785 // Deeplinked video: pin it to the top of the list so page 1 starts with the shared video
786 // while the per-page count stays exact. The pin reorders the whole list (not just page 1),
787 // so paginated AJAX requests passing the same id never repeat or skip videos. Ignored
788 // while searching — search results are a fresh listing of their own.
789 $featured_video_id = '';
790
791 if ( empty( $params['searchTerm'] ) && ! empty( $params['featured_video_id'] ) ) {
792 $featured_video_id = (string) $params['featured_video_id'];
793 $order_by = '(v.video_id = %s) DESC, ' . $order_by;
794 }
795
796 // Assemble placeholder values in SQL order: WHERE ..., [featured video], [seed], LIMIT, OFFSET.
797 $query_values = $values;
798
799 if ( '' !== $featured_video_id ) {
800 $query_values[] = $featured_video_id;
801 }
802
803 if ( $random_seed > 0 ) {
804 $query_values[] = $random_seed;
805 }
806
807 $query_values[] = $limit;
808 $query_values[] = $offset;
809
810 $query = $wpdb->prepare(
811 "SELECT v.*
812 FROM $videos_table AS v
813 INNER JOIN $rel_table AS r ON v.video_id = r.video_id
814 WHERE $where
815 ORDER BY $order_by
816 LIMIT %d OFFSET %d",
817 $query_values
818 );
819
820 $videos = $wpdb->get_results( $query );
821
822 if ( empty( $videos ) ) {
823 return $this->get_error( __( 'No videos found matching your query.', 'automatic-youtube-gallery' ) );
824 }
825
826 foreach ( $videos as $index => $video ) {
827 if ( ! empty( $video->thumbnails ) ) {
828 $videos[ $index ]->thumbnails = ayg_maybe_unserialize( $video->thumbnails );
829 }
830
831 // Backward compat: templates reference $video->id as the YouTube video ID.
832 $videos[ $index ]->id = $video->video_id;
833 }
834
835 // Process output
836 $response = new stdClass();
837 $response->videos = $videos;
838
839 $response->page_info = array(
840 'videos_found' => $total_videos,
841 'total_pages' => $total_pages,
842 'paged' => $current_page
843 );
844
845 if ( $current_page > 1 ) {
846 $response->page_info['prev_page_token'] = $current_page - 1;
847 }
848
849 if ( $current_page < $total_pages ) {
850 $response->page_info['next_page_token'] = $current_page + 1;
851 }
852
853 return $response;
854 }
855
856 /**
857 * Get API URL by request.
858 *
859 * @since 1.0.0
860 * @access private
861 * @param array $name
862 * @return string
863 */
864 private function get_api_url( $name ) {
865 return $this->api_urls[ $name ];
866 }
867
868 /**
869 * Request data from the API server.
870 *
871 * @since 1.0.0
872 * @access private
873 * @param string $url YouTube API URL.
874 * @param array $params Array of query params.
875 * @param string $context "channel_id", "playlist_id", "videos", or "live"
876 * @return mixed
877 */
878 private function request_api( $url, $params, $context = 'videos' ) {
879 $params['key'] = $this->api_key;
880
881 // Build API URL
882 $cache_duration = 0;
883 if ( isset( $params['cache'] ) ) {
884 $cache_duration = (int) $params['cache'];
885 unset( $params['cache'] );
886 }
887 $cache_duration = min( $cache_duration, 2419200 ); // Max cache duration: 1 Month
888
889 // Every parameter — the search term "q" included — goes through http_build_query() so it is
890 // URL encoded. "q" used to be appended to the URL raw, which meant a caller could smuggle
891 // extra parameters into the outbound request by putting "&" in the search keywords.
892 $api_url = $url . ( strpos( $url, '?' ) === false ? '?' : '&' ) . http_build_query( $params );
893
894 // Prefix the cache key with the gallery uid so a single gallery's transients can be cleared
895 // on demand (e.g. saving a live search/livestream gallery) via ayg_delete_cache( $uid ).
896 $cache_uid = isset( $this->params['uid'] ) ? (string) $this->params['uid'] : '';
897 $cache_key = 'ayg_' . ( '' !== $cache_uid ? $cache_uid . '_' : '' ) . md5( $api_url );
898
899 // Request from cache
900 if ( ! $this->is_development_mode && $cache_duration > 0 ) {
901 $cache_data = get_transient( $cache_key );
902
903 if ( ! empty( $cache_data ) ) {
904 return $cache_data;
905 }
906 }
907
908 // Request from API
909 $timeout = apply_filters( 'ayg_api_request_timeout', 15 );
910
911 $request = wp_remote_get( $api_url, array(
912 'headers' => [ 'referer' => home_url() ],
913 'timeout' => $timeout, // Increase timeout if needed
914 ) );
915
916 if ( is_wp_error( $request ) ) {
917 return $this->get_error( $request->get_error_message() );
918 }
919
920 $body = wp_remote_retrieve_body( $request );
921 $data = json_decode( $body );
922
923 if ( empty( $data ) ) {
924 return $this->get_error( __( 'Empty or invalid API response', 'automatic-youtube-gallery' ) );
925 }
926
927 if ( isset( $data->error ) ) {
928 $message = "Error " . $data->error->code . " " . $data->error->message;
929
930 if ( isset( $data->error->errors[0] ) ) {
931 $message .= " : " . $data->error->errors[0]->reason;
932 }
933
934 return $this->get_error( $message );
935 }
936
937 // Store in cache (transients)
938 $cache_enabled = false;
939
940 if ( ! $this->is_development_mode && $cache_duration > 0 ) {
941 if ( 'videos' === $context ) {
942 if ( ! empty( $data->items ) && is_array( $data->items ) ) {
943 $cache_enabled = true;
944 }
945 }
946
947 if ( 'live' === $context ) {
948 $cache_enabled = true;
949 }
950 }
951
952 if ( $cache_enabled ) {
953 set_transient( $cache_key, $data, $cache_duration );
954
955 // Get the current list of transients
956 $cache_keys = ayg_get_option( 'ayg_transient_keys' );
957
958 // Append our new one
959 if ( ! in_array( $cache_key, $cache_keys ) ) {
960 $cache_keys[] = $cache_key;
961 }
962
963 // Save it to the DB (autoload=no: this list can grow large and is not needed on every page load)
964 update_option( 'ayg_transient_keys', $cache_keys, false );
965 }
966
967 // Store videos in our custom database table "{$wpdb->prefix}ayg_videos"
968 //
969 // What reaches the storage layer is rebuilt from an explicit allowlist instead of being
970 // handed the raw request params, so the allowlist that governs the outbound request now
971 // governs the database write made from the same function. Gallery membership ("uid") is
972 // the value that decides what a gallery displays, so it is only passed along when the
973 // caller opted in through "store" — never on the strength of a "uid" alone.
974 $store_attributes = array(
975 'type' => isset( $this->params['type'] ) ? $this->params['type'] : '',
976 'exclude' => ( isset( $this->params['exclude'] ) && is_array( $this->params['exclude'] ) ) ? $this->params['exclude'] : array()
977 );
978
979 if ( $this->can_store && ! empty( $this->params['uid'] ) ) {
980 $store_attributes['uid'] = (string) $this->params['uid'];
981 }
982
983 ayg_db_store_videos( $data, $store_attributes );
984
985 // Finally return the data
986 return $data;
987 }
988
989 /**
990 * Parse videos from the YouTube API response object.
991 *
992 * @since 1.0.0
993 * @access private
994 * @param object $data YouTube API response object.
995 * @return mixed
996 */
997 private function parse_videos( $data ) {
998 if ( empty( $data->items ) || ! is_array( $data->items ) ) {
999 $error = $this->get_error( __( 'No videos found matching your query.', 'automatic-youtube-gallery' ) );
1000
1001 // Flag an empty result set so an incremental sync can tell "nothing new" apart
1002 // from a genuine API failure (quota, bad source) and complete cleanly.
1003 $error->no_results = true;
1004
1005 return $error;
1006 }
1007
1008 $items = $data->items;
1009 $videos = array();
1010 $exclude = ( isset( $this->params['exclude'] ) && is_array( $this->params['exclude'] ) ) ? $this->params['exclude'] : array();
1011
1012 foreach ( $items as $item ) {
1013 $video = new stdClass();
1014
1015 // Video ID
1016 $video->id = '';
1017
1018 if ( isset( $item->snippet->resourceId ) && isset( $item->snippet->resourceId->videoId ) ) {
1019 $video->id = $item->snippet->resourceId->videoId;
1020 } elseif ( isset( $item->contentDetails ) && isset( $item->contentDetails->videoId ) ) {
1021 $video->id = $item->contentDetails->videoId;
1022 } elseif ( isset( $item->id ) && isset( $item->id->videoId ) ) {
1023 $video->id = $item->id->videoId;
1024 } elseif ( isset( $item->id ) ) {
1025 $video->id = $item->id;
1026 }
1027
1028 // Skip videos on the gallery's exclude list
1029 if ( ayg_is_video_excluded( $video->id, $exclude ) ) {
1030 continue;
1031 }
1032
1033 // Video channel ID
1034 $video->channel_id = '';
1035
1036 if ( isset( $item->snippet->channelId ) ) {
1037 $video->channel_id = $item->snippet->channelId;
1038 }
1039
1040 // Video title
1041 $video->title = $item->snippet->title;
1042
1043 // Video description
1044 $video->description = $item->snippet->description;
1045
1046 // Video thumbnails
1047 if ( isset( $item->snippet->thumbnails ) ) {
1048 $video->thumbnails = $item->snippet->thumbnails;
1049 }
1050
1051 // Video publish date
1052 $video->published_at = $item->snippet->publishedAt;
1053
1054 // Push resulting object to the main array
1055 $status = 'private';
1056
1057 if ( isset( $item->status ) && ( 'public' == $item->status->privacyStatus || 'unlisted' == $item->status->privacyStatus ) ) {
1058 $status = 'public';
1059 }
1060
1061 if ( isset( $item->snippet->status ) && ( 'public' == $item->snippet->status->privacyStatus || 'unlisted' == $item->snippet->status->privacyStatus ) ) {
1062 $status = 'public';
1063 }
1064
1065 if ( 'youtube#searchResult' == $item->kind ) {
1066 $status = 'public';
1067 }
1068
1069 if ( 'public' == $status ) {
1070 $videos[] = $video;
1071 }
1072 }
1073
1074 if ( 0 == count( $videos ) ) {
1075 return $this->get_error( __( 'No videos found matching your query.', 'automatic-youtube-gallery' ) );
1076 }
1077
1078 return $videos;
1079 }
1080
1081 /**
1082 * Enrich parsed videos with duration and live broadcast details.
1083 *
1084 * playlistItems.list / search.list responses lack contentDetails.duration and
1085 * snippet.liveBroadcastContent, so this makes a supplementary videos.list request (1 quota
1086 * unit per 50 IDs) — updating the stored rows and merging duration + video_type into $videos.
1087 *
1088 * @since 2.8.0
1089 * @access private
1090 * @param array $videos Parsed video objects from parse_videos().
1091 * @return array The same videos, with duration / duration_seconds / video_type set.
1092 */
1093 private function enrich_video_details( $videos ) {
1094 // Index by video ID for the merge. PHP holds objects by handle, so mutating a $map
1095 // entry below also updates the same instance in $videos (what we return).
1096 $map = array();
1097
1098 foreach ( $videos as $video ) {
1099 if ( ! empty( $video->id ) ) {
1100 $map[ $video->id ] = $video;
1101 }
1102 }
1103
1104 if ( empty( $map ) ) {
1105 return $videos;
1106 }
1107
1108 $api_url = $this->get_api_url( 'videos.list' );
1109
1110 // videos.list accepts up to 50 IDs per request.
1111 foreach ( array_chunk( array_keys( $map ), 50 ) as $chunk ) {
1112 $api_params = array(
1113 'id' => implode( ',', $chunk ),
1114 'part' => 'id,snippet,contentDetails,status'
1115 );
1116
1117 // request_api() also stores the enriched rows (videos.list response →
1118 // ayg_db_store_videos() writes duration_seconds + video_type).
1119 $api_response = $this->request_api( $api_url, $api_params );
1120
1121 if ( isset( $api_response->error ) || empty( $api_response->items ) || ! is_array( $api_response->items ) ) {
1122 continue;
1123 }
1124
1125 foreach ( $api_response->items as $item ) {
1126 if ( empty( $item->id ) || ! isset( $map[ $item->id ] ) ) {
1127 continue;
1128 }
1129
1130 $duration = isset( $item->contentDetails->duration ) ? $item->contentDetails->duration : '';
1131
1132 $map[ $item->id ]->duration = $duration;
1133 $map[ $item->id ]->duration_seconds = ayg_parse_duration_seconds( $duration );
1134 $map[ $item->id ]->video_type = ! empty( $item->snippet->liveBroadcastContent ) ? $item->snippet->liveBroadcastContent : 'none';
1135 }
1136 }
1137
1138 return $videos;
1139 }
1140
1141 /**
1142 * Parse page info from the YouTube API response object.
1143 *
1144 * @since 1.0.0
1145 * @access private
1146 * @param object $data YouTube API response object.
1147 * @return array
1148 */
1149 private function parse_page_info( $data ) {
1150 $page_info = array(
1151 'videos_found' => 0
1152 );
1153
1154 // Total number of videos found
1155 if ( isset( $data->pageInfo ) && isset( $data->pageInfo->totalResults ) ) {
1156 $page_info['videos_found'] = (int) $data->pageInfo->totalResults;
1157 }
1158
1159 // Calculate total number of pages
1160 if ( $page_info['videos_found'] > 0 ) {
1161 if ( 'search' == $this->params['type'] ) {
1162 $limit = min( (int) $this->params['limit'], $page_info['videos_found'] );
1163 $page_info['total_pages'] = ceil( $limit / (int) $this->params['maxResults'] );
1164 } else {
1165 $max_results = (int) $this->params['maxResults'];
1166 $page_info['total_pages'] = ( $max_results <= 0 ) ? 1 : ceil( $page_info['videos_found'] / $max_results );
1167 }
1168 }
1169
1170 // Token for the previous page
1171 if ( isset( $data->prevPageToken ) ) {
1172 $page_info['prev_page_token'] = $data->prevPageToken;
1173 }
1174
1175 // Token for the next page
1176 if ( isset( $data->nextPageToken ) ) {
1177 $page_info['next_page_token'] = $data->nextPageToken;
1178 }
1179
1180 return $page_info;
1181 }
1182
1183 /**
1184 * Combine user params with known params and fill in defaults when needed.
1185 *
1186 * @since 1.0.0
1187 * @access private
1188 * @param array $pairs Entire list of supported params and their defaults.
1189 * @param array $params User defined params.
1190 * @return array $out Combined and filtered params array.
1191 */
1192 private function safe_merge_params( $pairs, $params ) {
1193 $params = (array) $params;
1194 $out = array();
1195
1196 foreach ( $pairs as $name => $default ) {
1197 if ( array_key_exists( $name, $params ) ) {
1198 $out[ $name ] = $params[ $name ];
1199 } else {
1200 $out[ $name ] = $default;
1201 }
1202
1203 if ( empty( $out[ $name ] ) ) {
1204 unset( $out[ $name ] );
1205 }
1206 }
1207
1208 return $out;
1209 }
1210
1211 /**
1212 * Build error object.
1213 *
1214 * @since 1.0.0
1215 * @access private
1216 * @param string $message Error message.
1217 * @return object Error object.
1218 */
1219 private function get_error( $message ) {
1220 $obj = new stdClass();
1221 $obj->error = 1;
1222 $obj->error_message = $message;
1223
1224 return $obj;
1225 }
1226
1227 }
1228