'', 'sort_by' => 'date', 'sort_order' => 'desc', 'sort_seed' => '', 'duration_filter' => '', 'duration' => 0 ); foreach ( $fields as $key => $value ) { foreach ( $value['fields'] as $field ) { if ( in_array( $field['name'], $excluded ) ) { continue; } $defaults[ $field['name'] ] = $field['value']; } } $defaults = array_merge( $defaults, (array) $strings_settings ); // Gallery Builder mode: a numeric "id" loads the saved gallery config, layers its saved // settings under $args (shortcode attributes still win, except the source), and switches // the source to the internal "db" type so the existing build pipeline serves it from the DB. if ( isset( $args['id'] ) && is_numeric( $args['id'] ) && (int) $args['id'] > 0 ) { $config = ayg_get_gallery( (int) $args['id'] ); if ( ! $config ) { return sprintf( '
%s
', esc_html__( 'Gallery not found.', 'automatic-youtube-gallery' ) ); } $config_params = json_decode( (string) $config->params, true ); if ( ! is_array( $config_params ) ) { $config_params = array(); } // Saved settings act as defaults; explicit shortcode attributes override them. $args = array_merge( $config_params, $args ); // The source always comes from the config and can't be overridden by the shortcode. // Livestream, search and single video query the live API at display time (nothing is // imported/stored); everything else renders from the DB. ayg_get_gallery_source() owns that // mapping so the public AJAX endpoint resolves the same source from the same saved row. $source = ayg_get_gallery_source( $config ); $args['type'] = $source['type']; $args[ $source['field'] ] = $source['src']; // Single video + popup mode → force the popup theme, mirroring the block and widget (which // apply this before calling ayg_build_gallery()). if ( 'video' === $config->source_type && ! empty( $args['popup'] ) ) { $args['theme'] = 'popup'; } // Gallery Builder relationships are keyed by the config ID as a string. $args['uid'] = strval( $config->id ); } $attributes = shortcode_atts( $defaults, $args, 'automatic_youtube_gallery' ); $attributes['post_id'] = 0; if ( isset( $post->ID ) ) { $attributes['post_id'] = (int) $post->ID; } $attributes['columns'] = min( 12, (int) $attributes['columns'] ); if ( empty( $attributes['columns'] ) ) { $attributes['columns'] = 3; } $attributes['limit'] = min( 500, (int) $attributes['limit'] ); if ( empty( $attributes['limit'] ) ) { $attributes['limit'] = 500; } $source_type = sanitize_text_field( $attributes['type'] ); // Videos per page. Database (Gallery Builder) galleries read from our own table, so 0 means // "show all videos on a single page" (no pagination). The live-API sources (search, livestream) // stay capped at 50 because that is YouTube's maximum number of results per request. if ( 'db' === $source_type ) { $attributes['per_page'] = max( 0, (int) $attributes['per_page'] ); } else { $attributes['per_page'] = min( 50, (int) $attributes['per_page'] ); if ( empty( $attributes['per_page'] ) ) { $attributes['per_page'] = 50; } } if ( 'livestream' == $source_type ) { $attributes['livestream'] = $attributes['channel']; } $attributes['lazyload'] = 0; if ( ! empty( $general_settings['lazyload'] ) ) { $attributes['lazyload'] = 1; } $source_url = sanitize_text_field( $attributes[ $source_type ] ); if ( isset( $args['id'] ) && ! empty( $args['id'] ) ) { $attributes['id'] = absint( $args['id'] ); } if ( isset( $args['uid'] ) && ! empty( $args['uid'] ) ) { $attributes['uid'] = sanitize_text_field( $args['uid'] ); } else { $attributes['uid'] = md5( $source_type . $source_url ); } $attributes['uid'] = apply_filters( 'ayg_gallery_id', $attributes['uid'], $args ); // Deprecated since v2.5.8. Retained for backward compatibility. $deprecated_uid = md5( $source_type . $source_url . sanitize_text_field( $attributes['theme'] ) ); // Deprecated if ( isset( $args['deprecated_uid'] ) && ! empty( $args['deprecated_uid'] ) ) { $deprecated_uid = sanitize_text_field( $args['deprecated_uid'] ); } // Deeplinked video (premium): resolve before the query so DB-served galleries can pin it as // the first item at the SQL level, keeping the per-page count exact. API-served (legacy) // galleries keep the old prepend behavior below. $gallery_id_from_url = get_query_var( 'ayg_gallery_id' ); $video_id_from_url = get_query_var( 'ayg_video_id' ); $deeplinked_video = false; if ( ! empty( $video_id_from_url ) && ( $attributes['uid'] == $gallery_id_from_url || $deprecated_uid == $gallery_id_from_url ) ) { $deeplinked_video = ayg_db_get_video( $video_id_from_url ); } if ( ! $deeplinked_video ) { $video_id_from_url = ''; } $featured_video_id = ( 'db' === $source_type && ! empty( $video_id_from_url ) ) ? sanitize_text_field( $video_id_from_url ) : ''; // Random ordering: generate a per-render seed (once) so RAND(seed) stays stable across this // gallery's own pagination/search AJAX calls. A seed hardcoded in the shortcode is respected. if ( 'random' === $attributes['sort_by'] && empty( $attributes['sort_seed'] ) ) { $attributes['sort_seed'] = wp_rand( 1, 2147483647 ); } // Get Videos $api_params = array( 'uid' => $attributes['uid'], 'type' => $source_type, 'src' => $source_url, 'store' => true, // Trusted context: the source comes from the rendered shortcode, not from a visitor's request. 'featured_video_id' => $featured_video_id, // Works only when type = "db". 'order' => sanitize_text_field( $attributes['order'] ), // Works only when type = "search". 'sort_by' => sanitize_key( $attributes['sort_by'] ), // Works only when type = "db". 'sort_order' => sanitize_key( $attributes['sort_order'] ), // Works only when type = "db". 'sort_seed' => absint( $attributes['sort_seed'] ), // Works only when type = "db" + sort_by = "random". 'duration_filter' => sanitize_key( $attributes['duration_filter'] ), // Works only when type = "db". 'duration' => absint( $attributes['duration'] ), // Works only when type = "db". 'limit' => $attributes['limit'], // Works only when type = "search". 'maxResults' => $attributes['per_page'], 'cache' => (int) $attributes['cache'] ); $api_params = apply_filters( 'ayg_youtube_api_request_params', $api_params, $args ); $youtube_api = new AYG_YouTube_API(); $response = $youtube_api->query( $api_params ); // Process output if ( ! isset( $response->error ) ) { // Store Gallery ID if ( $attributes['post_id'] > 0 && isset( $attributes['deeplinking'] ) && 1 == $attributes['deeplinking'] ) { $pages = ayg_get_option( 'ayg_gallery_page_ids' ); $page_id = $attributes['post_id']; if ( ! in_array( $page_id, $pages ) ) { $pages[] = $page_id; update_option( 'ayg_gallery_page_ids', $pages ); } } // Gallery $videos = array(); if ( ! empty( $video_id_from_url ) ) { // DB-served galleries pin the deeplinked video via the SQL query itself, so the result // set already starts with it. Fall back to prepending it only when the pin couldn't // apply: API-served (legacy) sources, or the video is no longer part of this gallery's // result set (removed from the gallery, or excluded by a duration filter). $is_pinned_by_sql = ! empty( $api_params['featured_video_id'] ) && ! empty( $response->videos ) && $response->videos[0]->id == $video_id_from_url; if ( ! $is_pinned_by_sql ) { $videos[] = $deeplinked_video; } } if ( isset( $response->videos ) ) { if ( ! empty( $videos ) ) { foreach ( $response->videos as $video ) { if ( $video->id != $video_id_from_url ) { $videos[] = $video; } } } else { $videos = $response->videos; } } // Pagination if ( isset( $response->page_info ) ) { $page_info = $response->page_info; // Sign the page tokens on their way out to the browser so the public AJAX endpoint can // tell the tokens this site issued from ones a visitor made up. if ( ayg_page_token_is_signed( $source_type ) ) { $page_info = ayg_sign_page_tokens( $page_info, $attributes['uid'] ); } $attributes = array_merge( $attributes, $api_params, $page_info ); } // Theme $theme = 'classic'; if ( 'video' == $source_type ) { $theme = 'single'; } elseif ( 'livestream' == $source_type ) { $theme = 'livestream'; } else { if ( 1 == count( $videos ) ) { $theme = 'single'; } } // Enqueue dependencies wp_enqueue_style( AYG_SLUG . '-public' ); wp_enqueue_script( AYG_SLUG . '-public' ); if ( $attributes['theme'] == $theme && 'classic' == $attributes['theme'] ) { wp_enqueue_script( AYG_SLUG . '-theme-classic' ); } // Output ob_start(); include ayg_get_template( AYG_DIR . "public/templates/theme-{$theme}.php", $attributes['theme'] ); return ob_get_clean(); } else { return sprintf( '
%s
', wp_kses_post( $response->error_message ) ); } } /** * Combine video attributes as a string. * * @since 2.5.0 * @param array $atts Array of video attributes. * @param string Combined attributes string. */ function ayg_combine_video_attributes( $atts ) { $attributes = array(); foreach ( $atts as $key => $value ) { if ( '' === $value ) { $attributes[] = $key; } else { $attributes[] = sprintf( '%s="%s"', $key, $value ); } } return implode( ' ', $attributes ); } /** * Create custom database tables * * @since 2.1.0 */ function ayg_db_create_custom_tables() { global $wpdb; require_once( ABSPATH . 'wp-admin/includes/upgrade.php' ); $charset_collate = $wpdb->get_charset_collate(); $videos_table = $wpdb->prefix . 'ayg_videos'; $rel_table = $wpdb->prefix . 'ayg_gallery_relationships'; $old_table = $wpdb->prefix . 'ayg_galleries'; // Rename wp_ayg_galleries (old pivot) → wp_ayg_gallery_relationships. // Guard: only when the old table exists AND the new name does not yet exist AND // the old table is the pivot (identified by having a video_id column). $old_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $old_table ) ) ) === $old_table; $new_not_exist = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $rel_table ) ) ) !== $rel_table; if ( $old_exists && $new_not_exist ) { $is_old_pivot = ! empty( $wpdb->get_results( "SHOW COLUMNS FROM `$old_table` LIKE 'video_id'" ) ); if ( $is_old_pivot ) { $renamed = $wpdb->query( "RENAME TABLE `$old_table` TO `$rel_table`" ); if ( false === $renamed ) { // Rename failed (insufficient DB privilege on some hosts). // This table holds auto-generated cache data, not user content — safe to drop. $wpdb->query( "DROP TABLE IF EXISTS `$old_table`" ); } } } // Rename columns in wp_ayg_videos (NUM → id, id → video_id). // Each ALTER is guarded so the block is idempotent on re-runs. $videos_table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $videos_table ) ) ) === $videos_table; if ( $videos_table_exists ) { // Rename 'id' → 'video_id' first. Must happen before NUM → id to avoid a duplicate // column name error: if NUM were renamed to 'id' while the varchar 'id' still exists, // MySQL would reject it with "Duplicate column name 'id'". $old_id_col = $wpdb->get_row( "SHOW COLUMNS FROM `$videos_table` LIKE 'id'" ); if ( $old_id_col && false !== strpos( strtolower( $old_id_col->Type ), 'varchar' ) ) { $wpdb->query( "ALTER TABLE `$videos_table` CHANGE `id` `video_id` varchar(100) NOT NULL" ); } // Now safe to rename NUM → id (no column named 'id' exists at this point). $has_num = ! empty( $wpdb->get_results( "SHOW COLUMNS FROM `$videos_table` LIKE 'NUM'" ) ); if ( $has_num ) { $wpdb->query( "ALTER TABLE `$videos_table` CHANGE `NUM` `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT" ); } // Remove duplicate rows using the new column names (idempotent). $wpdb->query( "DELETE v1 FROM $videos_table v1 INNER JOIN $videos_table v2 ON v1.video_id = v2.video_id AND v1.id > v2.id" ); } // Create/update wp_ayg_videos — dbDelta adds the 2 new columns on existing installs. $sql = "CREATE TABLE $videos_table ( id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, video_id varchar(100) NOT NULL, title text NOT NULL, description text NOT NULL, thumbnails text NOT NULL, duration varchar(25) NOT NULL, duration_seconds int(11) UNSIGNED NOT NULL DEFAULT 0, video_type varchar(20) NOT NULL DEFAULT 'none', status varchar(100) NOT NULL, published_at varchar(100) NOT NULL, published_at_datetime datetime NULL, PRIMARY KEY (id), UNIQUE KEY ayg_unique_video_id (video_id), INDEX ayg_idx_published_at_datetime (published_at_datetime) ) $charset_collate;"; dbDelta( $sql ); // Fallback: add UNIQUE KEY if dbDelta did not create it (edge case on existing installs). $existing_keys = $wpdb->get_results( "SHOW KEYS FROM $videos_table WHERE Key_name = 'ayg_unique_video_id'" ); if ( empty( $existing_keys ) ) { $wpdb->query( "ALTER TABLE $videos_table ADD UNIQUE KEY ayg_unique_video_id (video_id)" ); } // Fallback: add INDEX on published_at_datetime if missing (existing installs). $existing_dt_index = $wpdb->get_results( "SHOW KEYS FROM $videos_table WHERE Key_name = 'ayg_idx_published_at_datetime'" ); if ( empty( $existing_dt_index ) ) { $wpdb->query( "ALTER TABLE $videos_table ADD INDEX ayg_idx_published_at_datetime (published_at_datetime)" ); } // Rename NUM → id in wp_ayg_gallery_relationships (only if rename succeeded above). // If the table was recreated fresh by dbDelta below, it already has 'id' — no ALTER needed. $rel_table_exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $rel_table ) ) ) === $rel_table; if ( $rel_table_exists ) { $has_num_in_rel = ! empty( $wpdb->get_results( "SHOW COLUMNS FROM `$rel_table` LIKE 'NUM'" ) ); if ( $has_num_in_rel ) { $wpdb->query( "ALTER TABLE `$rel_table` CHANGE `NUM` `id` bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT" ); } } // Create/update wp_ayg_gallery_relationships (fresh creation if the rename fallback dropped the old table). $sql = "CREATE TABLE $rel_table ( id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, video_id varchar(100) NOT NULL, gallery_id varchar(100) NOT NULL, synced_at datetime NULL, PRIMARY KEY (id), UNIQUE KEY ayg_unique_video_gallery (video_id, gallery_id), INDEX ayg_idx_gallery_id (gallery_id) ) $charset_collate;"; dbDelta( $sql ); // Fallback: add the synced_at column if dbDelta did not (existing installs). $has_synced_at = ! empty( $wpdb->get_results( "SHOW COLUMNS FROM `$rel_table` LIKE 'synced_at'" ) ); if ( ! $has_synced_at ) { $wpdb->query( "ALTER TABLE `$rel_table` ADD `synced_at` datetime NULL" ); } // Create new wp_ayg_galleries entity table (one row per saved gallery config). $galleries_table = $wpdb->prefix . 'ayg_galleries'; $sql = "CREATE TABLE $galleries_table ( id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, title text NOT NULL, source_type varchar(50) NOT NULL DEFAULT 'channel', source_value text NOT NULL, params longtext NOT NULL, import_status varchar(20) NOT NULL DEFAULT 'idle', import_error text NULL, import_log longtext NULL, last_imported_at datetime NULL, next_import_at datetime NULL, video_count int(11) UNSIGNED NOT NULL DEFAULT 0, created_at datetime NOT NULL, updated_at datetime NOT NULL, PRIMARY KEY (id), INDEX idx_import (next_import_at, import_status) ) $charset_collate;"; dbDelta( $sql ); } /** * Check whether any videos are linked to the given gallery UID. * * Lets the public AJAX endpoint tell a gallery this site actually displays from a UID a visitor * simply made up: a gallery that has been rendered even once always has its videos in the * relationship table, an invented UID never does. * * @since 2.9.0 * @param string $gallery_id Gallery UID. * @return bool True when at least one video is linked to the UID. */ function ayg_db_gallery_has_videos( $gallery_id ) { global $wpdb; $gallery_id = (string) $gallery_id; if ( '' === $gallery_id ) { return false; } $found = $wpdb->get_var( $wpdb->prepare( "SELECT 1 FROM {$wpdb->prefix}ayg_gallery_relationships WHERE gallery_id = %s LIMIT 1", $gallery_id ) ); return ! empty( $found ); } /** * Get a single video record from our custom database table "{$wpdb->prefix}ayg_videos" * * @since 2.1.0 * @param string $video_id YouTube Video ID. * @return mixed */ function ayg_db_get_video( $video_id ) { global $wpdb; $cache_key = 'ayg_'. $video_id; $row = wp_cache_get( $cache_key ); if ( false === $row ) { $query = $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}ayg_videos WHERE video_id = %s", $video_id ); $row = $wpdb->get_row( $query ); if ( $row ) { if ( ! empty( $row->thumbnails ) ) { $row->thumbnails = ayg_maybe_unserialize( $row->thumbnails ); } // Backward compat: templates reference $video->id as the YouTube video ID. $row->id = $row->video_id; wp_cache_set( $cache_key, $row ); } } return $row; } /** * Store videos in our custom database table "{$wpdb->prefix}ayg_videos" * * @since 2.1.0 * @param object $data YouTube API response object. * @param array $attributes Array of user attributes. */ function ayg_db_store_videos( $data, $attributes = array() ) { if ( AYG_VERSION !== get_option( 'ayg_version' ) ) { return false; } if ( isset( $data->kind ) && 'youtube#channelListResponse' == $data->kind ) { return false; } if ( empty( $data->items ) || ! is_array( $data->items ) ) { return false; } global $wpdb; $items = $data->items; $gallery_id = isset( $attributes['uid'] ) ? $attributes['uid'] : ''; $source_type = isset( $attributes['type'] ) ? $attributes['type'] : 'videos'; $store_gallery = ! empty( $gallery_id ) && 'livestream' !== $source_type; $exclude = ( isset( $attributes['exclude'] ) && is_array( $attributes['exclude'] ) ) ? $attributes['exclude'] : array(); // Only videos.list responses carry contentDetails.duration and snippet.liveBroadcastContent. // For other responses (playlistItems.list, search.list) the duration_seconds / video_type // columns are left out entirely so existing values are never clobbered with defaults. $has_video_details = isset( $data->kind ) && 'youtube#videoListResponse' === $data->kind; $videos_table = $wpdb->prefix . 'ayg_videos'; $galleries_table = $wpdb->prefix . 'ayg_gallery_relationships'; $video_placeholders = array(); $video_values = array(); $gallery_placeholders = array(); $gallery_values = array(); foreach ( $items as $item ) { $row = array(); // Video ID $row['video_id'] = ''; if ( isset( $item->snippet->resourceId ) && isset( $item->snippet->resourceId->videoId ) ) { $row['video_id'] = $item->snippet->resourceId->videoId; } elseif ( isset( $item->contentDetails ) && isset( $item->contentDetails->videoId ) ) { $row['video_id'] = $item->contentDetails->videoId; } elseif ( isset( $item->id ) && isset( $item->id->videoId ) ) { $row['video_id'] = $item->id->videoId; } elseif ( isset( $item->id ) ) { $row['video_id'] = $item->id; } if ( empty( $row['video_id'] ) ) { continue; } // Skip videos on the gallery's exclude list (kept out of both tables) if ( ayg_is_video_excluded( $row['video_id'], $exclude ) ) { continue; } // Video title $row['title'] = $item->snippet->title; // Video description $row['description'] = $item->snippet->description; // Video thumbnails $row['thumbnails'] = ''; if ( isset( $item->snippet->thumbnails ) ) { $row['thumbnails'] = serialize( $item->snippet->thumbnails ); } // Video duration $row['duration'] = ''; if ( isset( $item->contentDetails ) && isset( $item->contentDetails->duration ) ) { $row['duration'] = $item->contentDetails->duration; } if ( $has_video_details ) { $row['duration_seconds'] = ayg_parse_duration_seconds( $row['duration'] ); // Raw liveBroadcastContent value ('live', 'upcoming', 'none'); shorts are not // classified at import time — filter by duration_seconds <= 60 at query time. $row['video_type'] = 'none'; if ( ! empty( $item->snippet->liveBroadcastContent ) ) { $row['video_type'] = $item->snippet->liveBroadcastContent; } } // Video status $row['status'] = 'private'; if ( isset( $item->status ) && ( 'public' == $item->status->privacyStatus || 'unlisted' == $item->status->privacyStatus ) ) { $row['status'] = 'public'; } if ( isset( $item->snippet->status ) && ( 'public' == $item->snippet->status->privacyStatus || 'unlisted' == $item->snippet->status->privacyStatus ) ) { $row['status'] = 'public'; } if ( 'youtube#searchResult' == $item->kind ) { $row['status'] = 'public'; } // Video publish date $row['published_at'] = $item->snippet->publishedAt; $datetime = new DateTime( $item->snippet->publishedAt ); $row['published_at_datetime'] = date_format( $datetime, 'Y-m-d H:i:s' ); // Collect for bulk insert if ( $has_video_details ) { $video_placeholders[] = '(%s, %s, %s, %s, %s, %d, %s, %s, %s, %s)'; array_push( $video_values, $row['video_id'], $row['title'], $row['description'], $row['thumbnails'], $row['duration'], $row['duration_seconds'], $row['video_type'], $row['status'], $row['published_at'], $row['published_at_datetime'] ); } else { $video_placeholders[] = '(%s, %s, %s, %s, %s, %s, %s, %s)'; array_push( $video_values, $row['video_id'], $row['title'], $row['description'], $row['thumbnails'], $row['duration'], $row['status'], $row['published_at'], $row['published_at_datetime'] ); } if ( $store_gallery ) { $gallery_placeholders[] = '(%s, %s)'; array_push( $gallery_values, $row['video_id'], $gallery_id ); } } // Bulk insert videos (2 queries total instead of N×2) if ( ! empty( $video_placeholders ) ) { if ( $has_video_details ) { $sql = "INSERT INTO $videos_table (video_id, title, description, thumbnails, duration, duration_seconds, video_type, status, published_at, published_at_datetime) VALUES " . implode( ', ', $video_placeholders ) . " ON DUPLICATE KEY UPDATE title = VALUES(title), description = VALUES(description), thumbnails = VALUES(thumbnails), duration = VALUES(duration), duration_seconds = VALUES(duration_seconds), video_type = VALUES(video_type), status = VALUES(status), published_at = VALUES(published_at), published_at_datetime = VALUES(published_at_datetime)"; } else { $sql = "INSERT INTO $videos_table (video_id, title, description, thumbnails, duration, status, published_at, published_at_datetime) VALUES " . implode( ', ', $video_placeholders ) . " ON DUPLICATE KEY UPDATE title = VALUES(title), description = VALUES(description), thumbnails = VALUES(thumbnails), duration = VALUES(duration), status = VALUES(status), published_at = VALUES(published_at), published_at_datetime = VALUES(published_at_datetime)"; } $wpdb->query( $wpdb->prepare( $sql, $video_values ) ); } // Bulk insert gallery relationships (2 queries total instead of N×2) if ( ! empty( $gallery_placeholders ) ) { $sql = "INSERT IGNORE INTO $galleries_table (video_id, gallery_id) VALUES " . implode( ', ', $gallery_placeholders ); $wpdb->query( $wpdb->prepare( $sql, $gallery_values ) ); } } /** * Dump our plugin transients. * * @since 2.1.0 * @param string $uid Gallery uid to target. Empty string flushes all plugin transients. */ function ayg_delete_cache( $uid = '' ) { $uid = (string) $uid; $keys = (array) ayg_get_option( 'ayg_transient_keys' ); // Targeted: delete only the keys prefixed with this gallery's uid, keep the rest in the registry. if ( '' !== $uid ) { $prefix = 'ayg_' . $uid . '_'; $remaining = array(); foreach ( $keys as $key ) { if ( 0 === strpos( $key, $prefix ) ) { delete_transient( $key ); } else { $remaining[] = $key; } } update_option( 'ayg_transient_keys', $remaining, false ); return; } // Full flush: every transient, plus the cached gallery page-id lookup. delete_option( 'ayg_gallery_page_ids' ); foreach ( $keys as $key ) { delete_transient( $key ); } // Reset our DB value (autoload=no: not needed on every page load) update_option( 'ayg_transient_keys', array(), false ); } /** * Remove a gallery's excluded videos from the relationship table. * * @since 2.8.0 * @param int $gallery_id Gallery ID. * @param array $exclude Exclude entries (video IDs or URLs). * @return int Number of relationships removed. */ function ayg_delete_excluded_relationships( $gallery_id, $exclude ) { global $wpdb; $gallery_id = absint( $gallery_id ); if ( $gallery_id <= 0 || empty( $exclude ) || ! is_array( $exclude ) ) { return 0; } // Resolve the exclude entries (IDs or URLs) to video IDs. $video_ids = array(); foreach ( $exclude as $entry ) { $video_id = ayg_get_youtube_video_id( $entry ); if ( '' !== $video_id ) { $video_ids[] = $video_id; } } $video_ids = array_values( array_unique( $video_ids ) ); if ( empty( $video_ids ) ) { return 0; } $rel_table = $wpdb->prefix . 'ayg_gallery_relationships'; $placeholders = implode( ',', array_fill( 0, count( $video_ids ), '%s' ) ); return (int) $wpdb->query( $wpdb->prepare( "DELETE FROM $rel_table WHERE gallery_id = %s AND video_id IN ( $placeholders )", array_merge( array( strval( $gallery_id ) ), $video_ids ) ) ); } /** * Get current address bar URL. * * @since 2.1.0 * @return string Current Page URL. */ function ayg_get_current_url() { global $wp; $current_url = home_url( add_query_arg( array(), $wp->request ) ); return $current_url; } /** * Get default plugin settings. * * @since 1.6.4 * @return array $defaults Array of plugin settings. */ function ayg_get_default_settings() { $defaults = array( 'ayg_general_settings' => array( 'force_load_assets' => array( 'css' => 'css' ), 'api_key' => '', 'lazyload' => 0, 'development_mode' => 0 ), 'ayg_strings_settings' => array( 'more_button_label' => __( 'Load More', 'automatic-youtube-gallery' ), 'previous_button_label' => __( 'Previous', 'automatic-youtube-gallery' ), 'next_button_label' => __( 'Next', 'automatic-youtube-gallery' ), 'show_more_label' => __( 'Show More', 'automatic-youtube-gallery' ), 'show_less_label' => __( 'Show Less', 'automatic-youtube-gallery' ) ), 'ayg_gallery_settings' => array( 'theme' => 'classic', 'columns' => 3, 'per_page' => 12, 'thumb_ratio' => 56.25, 'thumb_title' => 1, 'thumb_title_length' => 0, 'thumb_excerpt' => 1, 'thumb_excerpt_length' => 75, 'pagination' => 1, 'pagination_type' => 'more', 'scroll_top_offset' => 10 ), 'ayg_player_settings' => array( 'player_type' => 'youtube', 'player_color' => '#00b3ff', 'player_width' => '', 'player_ratio' => 56.25, 'player_title' => 1, 'player_description' => 1, 'autoplay' => 0, 'autoadvance' => 1, 'loop' => 0, 'muted' => 0, 'controls' => 1, 'modestbranding' => 1, 'cc_load_policy' => 0, 'hl' => '', 'cc_lang_pref' => '', 'privacy_enhanced_mode' => 0, 'origin' => 0 ), 'ayg_livestream_settings' => array( 'fallback_message' => __( 'Sorry, but the channel is not currently streaming live content. Please check back later.', 'automatic-youtube-gallery' ) ), 'ayg_privacy_settings' => array( 'cookie_consent' => 0, 'consent_message' => __( 'Please accept YouTube cookies to play this video. By accepting you will be accessing content from YouTube, a service provided by an external third party.', 'automatic-youtube-gallery' ), 'button_label' => __( 'Accept', 'automatic-youtube-gallery' ) ) ); return $defaults; } /** * Get editor fields. * * @since 1.0.0 * @return array Array of fields. */ function ayg_get_editor_fields() { $fields = array( 'source' => array( 'label' => __( 'Source Configuration', 'automatic-youtube-gallery' ), 'fields' => array( array( 'name' => 'type', 'label' => __( 'Source Type', 'automatic-youtube-gallery' ), 'description' => '', 'type' => 'select', 'options' => ayg_get_source_types(), 'value' => 'playlist', 'sanitize_callback' => 'sanitize_key' ), array( 'name' => 'playlist', 'label' => __( 'YouTube Playlist ID (or) URL', 'automatic-youtube-gallery' ), 'description' => sprintf( '%s: https://www.youtube.com/playlist?list=XXXXXXXXXX', __( 'Example', 'automatic-youtube-gallery' ) ), 'type' => 'url', 'required' => 1, 'value' => '', 'sanitize_callback' => 'sanitize_text_field' ), array( 'name' => 'channel', 'label' => __( 'YouTube Channel ID (or) a Video URL from the Channel', 'automatic-youtube-gallery' ), 'description' => sprintf( '%s: https://www.youtube.com/channel/XXXXXXXXXX', __( 'Example', 'automatic-youtube-gallery' ) ), 'type' => 'url', 'required' => 1, 'value' => '', 'sanitize_callback' => 'sanitize_text_field' ), array( 'name' => 'username', 'label' => __( 'YouTube Account Username', 'automatic-youtube-gallery' ), 'description' => sprintf( '%s: SanRosh', __( 'Example', 'automatic-youtube-gallery' ) ), 'type' => 'text', 'required' => 1, 'value' => '', 'sanitize_callback' => 'sanitize_text_field' ), array( 'name' => 'search', 'label' => __( 'Search Keywords', 'automatic-youtube-gallery' ), 'description' => sprintf( '%s: Cartoon (space:AND , -:NOT , |:OR)', __( 'Example', 'automatic-youtube-gallery' ) ), 'type' => 'text', 'required' => 1, 'value' => '', 'sanitize_callback' => 'sanitize_text_field' ), array( 'name' => 'video', 'label' => __( 'YouTube Video ID (or) URL', 'automatic-youtube-gallery' ), 'description' => sprintf( '%s: https://www.youtube.com/watch?v=XXXXXXXXXX', __( 'Example', 'automatic-youtube-gallery' ) ), 'type' => 'url', 'required' => 1, 'value' => '', 'sanitize_callback' => 'sanitize_text_field' ), array( 'name' => 'videos', 'label' => __( 'YouTube Video IDs (or) URLs', 'automatic-youtube-gallery' ), 'description' => sprintf( '%s: https://www.youtube.com/watch?v=XXXXXXXXXX', __( 'Example', 'automatic-youtube-gallery' ) ), 'type' => 'textarea', 'required' => 1, 'placeholder' => __( 'Enter one video per line', 'automatic-youtube-gallery' ), 'value' => '', 'sanitize_callback' => 'sanitize_textarea_field' ), array( 'name' => 'order', 'label' => __( 'Search Order', 'automatic-youtube-gallery' ), 'description' => '', 'type' => 'select', 'options' => array( 'date' => __( 'Date', 'automatic-youtube-gallery' ), 'rating' => __( 'Rating', 'automatic-youtube-gallery' ), 'relevance' => __( 'Relevance', 'automatic-youtube-gallery' ), 'title' => __( 'Title', 'automatic-youtube-gallery' ), 'viewCount' => __( 'View Count', 'automatic-youtube-gallery' ) ), 'value' => 'relevance', 'sanitize_callback' => 'sanitize_text_field' ), array( 'name' => 'limit', 'label' => __( 'Search Limit', 'automatic-youtube-gallery' ), 'description' => __( 'Enter the number of videos to display in this gallery. Set to 0 for the maximum amount (500).', 'automatic-youtube-gallery' ), 'type' => 'number', 'min' => 0, 'max' => 500, 'value' => 0, 'sanitize_callback' => 'intval' ), array( 'name' => 'cache', 'label' => __( 'Cache Duration', 'automatic-youtube-gallery' ), 'description' => __( 'Specifies how frequently we should check your YouTube source for new videos/updates.', 'automatic-youtube-gallery' ), 'type' => 'select', 'options' => array( '0' => '— '. __( 'No Caching', 'automatic-youtube-gallery' ) . ' —', '900' => __( '15 Minutes', 'automatic-youtube-gallery' ), '1800' => __( '30 Minutes', 'automatic-youtube-gallery' ), '3600' => __( '1 Hour', 'automatic-youtube-gallery' ), '86400' => __( '1 Day', 'automatic-youtube-gallery' ), '604800' => __( '1 Week', 'automatic-youtube-gallery' ), '2419200' => __( '1 Month', 'automatic-youtube-gallery' ) ), 'value' => 86400, 'sanitize_callback' => 'intval' ) ) ), 'gallery' => array( 'label' => __( 'Gallery Options', 'automatic-youtube-gallery' ), 'fields' => ayg_get_gallery_settings_fields() ), 'player' => array( 'label' => __( 'Player Options', 'automatic-youtube-gallery' ), 'fields' => ayg_get_player_settings_fields() ), 'search' => array( 'label' => __( 'Search Form', 'automatic-youtube-gallery' ), 'fields' => array( array( 'name' => 'search_form', 'label' => __( 'Search Form', 'automatic-youtube-gallery' ), 'description' => sprintf( __( 'Check this option to enable the search form. Having issues? Check here.', 'automatic-youtube-gallery' ), 'https://plugins360.com/automatic-youtube-gallery/searchform/' ), 'type' => 'checkbox', 'value' => 0, 'sanitize_callback' => 'intval' ) ) ) ); return apply_filters( 'ayg_editor_fields', $fields ); } /** * Fetch a single gallery row by ID. * * @since 2.8.0 * @param int $gallery_id Gallery ID. * @return object|null */ function ayg_get_gallery( $gallery_id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM `{$wpdb->prefix}ayg_galleries` WHERE id = %d", absint( $gallery_id ) ) ); } /** * Get gallery settings fields. * * @since 1.0.0 * @return array $fields Array of fields. */ function ayg_get_gallery_settings_fields() { $gallery_settings = ayg_get_option( 'ayg_gallery_settings' ); $fields = array( array( 'name' => 'theme', 'label' => __( 'Select Theme (Layout)', 'automatic-youtube-gallery' ), 'description' => ( ayg_fs()->is_not_paying() ? sprintf( __( 'Upgrade Pro for more themes (Popup, Inline, Slider, Playlist).', 'automatic-youtube-gallery' ), esc_url( ayg_fs()->get_upgrade_url() ) ) : '' ), 'type' => 'select', 'options' => array( 'classic' => __( 'Classic', 'automatic-youtube-gallery' ) ), 'value' => $gallery_settings['theme'], 'sanitize_callback' => 'sanitize_key' ), array( 'name' => 'columns', 'label' => __( 'Columns', 'automatic-youtube-gallery' ), 'description' => __( 'Enter the number of columns you like to have in the gallery. Maximum of 12.', 'automatic-youtube-gallery' ), 'type' => 'number', 'min' => 0, 'max' => 12, 'value' => $gallery_settings['columns'], 'sanitize_callback' => 'intval' ), array( 'name' => 'per_page', 'label' => __( 'Videos per Page', 'automatic-youtube-gallery' ), 'description' => __( 'Enter the number of videos to show per page. Maximum of 50.', 'automatic-youtube-gallery' ), 'type' => 'number', 'min' => 0, 'max' => 50, 'value' => $gallery_settings['per_page'], 'sanitize_callback' => 'intval' ), array( 'name' => 'thumb_ratio', 'label' => __( 'Image Height (Ratio)', 'automatic-youtube-gallery' ), 'description' => __( 'Select the ratio value used to calculate the image height in the gallery thumbnails.', 'automatic-youtube-gallery' ), 'type' => 'select', 'options' => array( '56.25' => __( 'Standard (16:9) — Default', 'automatic-youtube-gallery' ), '177.78' => __( 'Shorts / Vertical (9:16)', 'automatic-youtube-gallery' ), '75' => __( 'Classic (4:3)', 'automatic-youtube-gallery' ) ), 'value' => $gallery_settings['thumb_ratio'], 'sanitize_callback' => 'floatval' ), array( 'name' => 'thumb_title', 'label' => __( 'Show Video Title', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to show the video title in each gallery item.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $gallery_settings['thumb_title'], 'sanitize_callback' => 'intval' ), array( 'name' => 'thumb_title_length', 'label' => __( 'Video Title Length', 'automatic-youtube-gallery' ), 'description' => __( 'Enter the number of characters you like to show in the title. Set 0 to show the whole title.', 'automatic-youtube-gallery' ), 'type' => 'number', 'min' => 0, 'max' => 500, 'value' => $gallery_settings['thumb_title_length'], 'sanitize_callback' => 'intval' ), array( 'name' => 'thumb_excerpt', 'label' => __( 'Show Video Excerpt (Short Description)', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to show the short description of a video in each gallery item.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $gallery_settings['thumb_excerpt'], 'sanitize_callback' => 'intval' ), array( 'name' => 'thumb_excerpt_length', 'label' => __( 'Video Excerpt Length', 'automatic-youtube-gallery' ), 'description' => __( 'Enter the number of characters you like to have in the video excerpt. Set 0 to show the whole description.', 'automatic-youtube-gallery' ), 'type' => 'number', 'min' => 0, 'max' => 500, 'value' => $gallery_settings['thumb_excerpt_length'], 'sanitize_callback' => 'intval' ), array( 'name' => 'pagination', 'label' => __( 'Pagination', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to show the pagination.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $gallery_settings['pagination'], 'sanitize_callback' => 'intval' ), array( 'name' => 'pagination_type', 'label' => __( 'Pagination Type', 'automatic-youtube-gallery' ), 'type' => 'select', 'options' => array( 'more' => __( 'More Button', 'automatic-youtube-gallery' ), 'pager' => __( 'Pager', 'automatic-youtube-gallery' ) ), 'value' => $gallery_settings['pagination_type'], 'sanitize_callback' => 'sanitize_key' ) ); return apply_filters( 'ayg_gallery_settings_fields', $fields ); } /** * Build the signature that ties a gallery UID to the source and cache duration it was rendered * with. * * A gallery UID is printed into the page, so it is not a secret and cannot be trusted on its own. * What this protects is the *pairing*: a request may only ask for the source that was rendered * alongside that UID, at the cache duration that gallery was configured with. * * It exists because a UID cannot always be recomputed. ayg_build_gallery() derives it as * md5( type + source ), but a gallery can override that through the "uid" shortcode attribute or * the ayg_gallery_id filter, and neither is knowable from the request alone. Signing the whole set * at render time lets the public AJAX endpoint accept such galleries without also having to accept * whatever source — or cache duration — the caller claims goes with them. * * Including the cache duration is what lets the "Cache Duration" gallery option keep working * exactly as the site owner set it, right down to "No Caching", while still making it impossible * for a visitor to bypass the cache and force live API requests. * * Each part is length prefixed so that no two different sets of values can produce the same * string to sign. * * @since 2.9.0 * @param string $uid Gallery UID. * @param string $type Gallery source type. * @param string $src Gallery source value. * @param string|int $cache Gallery cache duration in seconds. * @return string Signature. */ function ayg_get_gallery_signature( $uid, $type, $src, $cache ) { $parts = array( (string) $uid, (string) $type, (string) $src, (string) (int) $cache ); $data = 'ayg_gallery'; foreach ( $parts as $part ) { $data .= '|' . strlen( $part ) . ':' . $part; } return substr( wp_hash( $data ), 0, 16 ); } /** * Resolve a saved gallery's source type and value. * * Livestream, search and single video galleries query the YouTube API at display time — nothing * is imported for them — so they keep their own source type. Everything else is served from the * custom tables through the internal "db" source type, keyed by the gallery ID. * * Both the renderer (ayg_build_gallery) and the public AJAX endpoint resolve a saved gallery's * source through this function, so an untrusted request can never point a gallery at a source * other than the one stored in its own row. * * @since 2.9.0 * @param object $gallery Gallery row from "{$wpdb->prefix}ayg_galleries". * @return array Source "type", the matching attribute name in "field", and the * source value in "src". */ function ayg_get_gallery_source( $gallery ) { $source_type = isset( $gallery->source_type ) ? $gallery->source_type : ''; $source_value = isset( $gallery->source_value ) ? $gallery->source_value : ''; if ( 'livestream' === $source_type ) { return array( 'type' => 'livestream', 'field' => 'channel', 'src' => $source_value ); } if ( 'search' === $source_type ) { return array( 'type' => 'search', 'field' => 'search', 'src' => $source_value ); } if ( 'video' === $source_type ) { return array( 'type' => 'video', 'field' => 'video', 'src' => $source_value ); } return array( 'type' => 'db', 'field' => 'db', 'src' => strval( $gallery->id ) ); } /** * Return the schedule interval options for import scheduling. * * @since 2.8.0 * @return array Value (seconds as string) => label pairs. */ function ayg_get_import_schedule_options() { return array( 'paused' => __( '— Pause Automatic Imports —', 'automatic-youtube-gallery' ), '0' => __( 'Only Once', 'automatic-youtube-gallery' ), '3600' => __( 'Every Hour', 'automatic-youtube-gallery' ), '86400' => __( 'Every Day', 'automatic-youtube-gallery' ), '604800' => __( 'Every Week', 'automatic-youtube-gallery' ), '2592000' => __( 'Every Month', 'automatic-youtube-gallery' ) ); } /** * Return the human-readable label for an import status key. * * @since 2.8.0 * @param string $status Import status key (idle, running, paused, error, completed). * @param int|null $video_count Optional. Imported video count, used to resolve the * ambiguous "idle" status into "Pending Import" (nothing * imported yet) or "Up to Date" (waiting for the next sync). * @param bool $resuming Optional. True when an import was capped mid-run and parked * as 'idle' for cron to resume — shown as "Importing", not idle. * @param string $context Optional. 'log' for a per-run history entry, where a successful * run (stored as 'idle' for recurring galleries) reads "Completed" * like non-recurring runs, instead of the live-state "Idle". * @return string */ function ayg_get_import_status_label( $status, $video_count = null, $resuming = false, $context = '' ) { if ( 'idle' === $status ) { // History rows record a finished run: a recurring gallery's success is stored as 'idle', // so surface it as "Completed" to match non-recurring runs rather than the live "Idle". if ( 'log' === $context ) { return __( 'Completed', 'automatic-youtube-gallery' ); } // If the import was capped mid-run and parked as 'idle' for cron to resume, show "Importing". if ( $resuming ) { return __( 'Importing', 'automatic-youtube-gallery' ); } // If the import has never run, show "Pending Import" instead of "Idle". if ( null !== $video_count ) { return ( (int) $video_count > 0 ) ? __( 'Up to Date', 'automatic-youtube-gallery' ) : __( 'Pending Import', 'automatic-youtube-gallery' ); } } $statuses = array( 'idle' => __( 'Idle', 'automatic-youtube-gallery' ), 'running' => __( 'Importing', 'automatic-youtube-gallery' ), 'paused' => __( 'Paused', 'automatic-youtube-gallery' ), 'error' => __( 'Error', 'automatic-youtube-gallery' ), 'completed' => __( 'Completed', 'automatic-youtube-gallery' ) ); return isset( $statuses[ $status ] ) ? $statuses[ $status ] : ucfirst( $status ); } /** * Retrieve a plugin option with fallback to default settings. * * @since 2.7.0 * @param string $option The option name to retrieve. * @return mixed */ function ayg_get_option( $option ) { $defaults = ayg_get_default_settings(); $default = isset( $defaults[ $option ] ) ? $defaults[ $option ] : array(); $saved = get_option( $option, null ); // Option does not exist OR corrupted if ( null === $saved || ! is_array( $saved ) ) { return $default; } // Merge saved values with defaults return wp_parse_args( $saved, $default ); } /** * Build the signature for a pagination token. * * Keyed on the site's own salts through wp_hash(), and bound to the gallery so a token issued for * one gallery cannot be replayed against another. Unlike a nonce this never expires, so tokens * printed into a page that is held by a full page cache keep working. * * @since 2.9.0 * @param string $token Raw page token. * @param string $uid Gallery UID the token belongs to. * @return string Signature. */ function ayg_get_page_token_signature( $token, $uid ) { return substr( wp_hash( 'ayg_page_token|' . (string) $uid . '|' . (string) $token ), 0, 16 ); } /** * Get video description to show on top of the player. * * @since 1.0.0 * @param stdClass $video YouTube video object. * @param array $attributes Array of user attributes. * @param int $words_count Number of words to show by default. * @return string Video description. */ function ayg_get_player_description( $video, $attributes = array(), $words_count = 30 ) { $description = $video->description; $words_array = explode( ' ', strip_tags( $description ) ); if ( count( $words_array ) > $words_count ) { $show_more_label = ! empty( $attributes['show_more_label'] ) ? $attributes['show_more_label'] : __( 'Show More', 'automatic-youtube-gallery' ); $words_array[ $words_count ] = '...' . $words_array[ $words_count ]; $description = '' . implode( ' ', $words_array ) . ''; $description .= '' . esc_html( $show_more_label ) . ''; } $description = nl2br( $description ); $description = make_clickable( $description ); return apply_filters( 'ayg_player_description', $description, $video, $attributes, $words_count ); } /** * Get player settings fields. * * @since 1.0.0 * @return array $fields Array of fields. */ function ayg_get_player_settings_fields() { $player_settings = ayg_get_option( 'ayg_player_settings' ); $fields = array( array( 'name' => 'player_width', 'label' => __( 'Player Width', 'automatic-youtube-gallery' ), 'description' => __( 'In pixels. Maximum width of the player. Leave this field empty to scale 100% of its enclosing container/html element.', 'automatic-youtube-gallery' ), 'type' => 'text', 'value' => isset( $player_settings['player_width'] ) ? $player_settings['player_width'] : '', 'sanitize_callback' => 'ayg_sanitize_int' ), array( 'name' => 'player_ratio', 'label' => __( 'Player Height (Ratio)', 'automatic-youtube-gallery' ), 'description' => __( 'Select the ratio value used to calculate the player height.', 'automatic-youtube-gallery' ), 'type' => 'select', 'options' => array( '56.25' => __( 'Standard (16:9) — Default', 'automatic-youtube-gallery' ), '177.78' => __( 'Shorts / Vertical (9:16)', 'automatic-youtube-gallery' ), '75' => __( 'Classic (4:3)', 'automatic-youtube-gallery' ) ), 'value' => $player_settings['player_ratio'], 'sanitize_callback' => 'floatval' ), array( 'name' => 'player_title', 'label' => __( 'Show Video Title', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to show the current playing video title on the bottom of the player.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['player_title'], 'sanitize_callback' => 'intval' ), array( 'name' => 'player_description', 'label' => __( 'Show Video Description', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to show the current playing video description on the bottom of the player.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['player_description'], 'sanitize_callback' => 'intval' ), array( 'name' => 'autoplay', 'label' => __( 'Autoplay', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to automatically start playing the initial video when the player loads.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['autoplay'], 'sanitize_callback' => 'intval' ), array( 'name' => 'autoadvance', 'label' => __( 'Autoplay Next Video', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to automatically play the next video in the list after the previous one ends.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['autoadvance'], 'sanitize_callback' => 'intval' ), array( 'name' => 'loop', 'label' => __( 'Loop', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to loop playback. In the case of a single video player, plays the initial video again and again. In the case of a gallery, plays the entire list and then starts again at the first video.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['loop'], 'sanitize_callback' => 'intval' ), array( 'name' => 'muted', 'label' => __( 'Muted', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to turn OFF the audio output of the video by default.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => isset( $player_settings['muted'] ) ? $player_settings['muted'] : 0, 'sanitize_callback' => 'intval' ), array( 'name' => 'controls', 'label' => __( 'Show Player Controls', 'automatic-youtube-gallery' ), 'description' => __( 'Uncheck this option to hide the video player controls.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['controls'], 'sanitize_callback' => 'intval' ), array( 'name' => 'modestbranding', 'label' => __( 'Hide YouTube Logo', 'automatic-youtube-gallery' ), 'description' => __( "Check this option to prevent the YouTube logo from displaying in the control bar. Note that a small YouTube text label will still display in the upper-right corner of a paused video when the user's mouse pointer hovers over the player.", 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['modestbranding'], 'sanitize_callback' => 'intval' ), array( 'name' => 'cc_load_policy', 'label' => __( 'Force Closed Captions', 'automatic-youtube-gallery' ), 'description' => __( 'Check this option to show captions by default, even if the user has turned captions off. The default behavior is based on user preference.', 'automatic-youtube-gallery' ), 'type' => 'checkbox', 'value' => $player_settings['cc_load_policy'], 'sanitize_callback' => 'intval' ), array( 'name' => 'hl', 'label' => __( 'Player Language', 'automatic-youtube-gallery' ), 'description' => sprintf( __( 'Specifies the player\'s interface language. Set the field\'s value to an ISO 639-1 two-letter language code.', 'automatic-youtube-gallery' ), 'http://www.loc.gov/standards/iso639-2/php/code_list.php' ), 'type' => 'text', 'value' => $player_settings['hl'], 'sanitize_callback' => 'sanitize_text_field' ), array( 'name' => 'cc_lang_pref', 'label' => __( 'Default Captions Language', 'automatic-youtube-gallery' ), 'description' => sprintf( __( 'Specifies the default language that the player will use to display captions. Set the field\'s value to an ISO 639-1 two-letter language code.', 'automatic-youtube-gallery' ), 'http://www.loc.gov/standards/iso639-2/php/code_list.php' ), 'type' => 'text', 'value' => $player_settings['cc_lang_pref'], 'sanitize_callback' => 'sanitize_text_field' ) ); return $fields; } /** * Get a single video page URL. * * @since 2.1.0 * @param stdClass $video YouTube video object. * @param array $args An array of gallery options. * @return string Single video URL. */ function ayg_get_single_video_url( $video, $attributes ) { return apply_filters( 'ayg_single_video_url', '', $video, $attributes ); } /** * Return the human-readable label for a source type key. * * @since 2.8.0 * @param string $type Source type key (channel, playlist, username, search, livestream, video, videos). * @return string */ function ayg_get_source_type_label( $type ) { $types = ayg_get_source_types(); return isset( $types[ $type ] ) ? $types[ $type ] : ucfirst( $type ); } /** * Get source types. * * @since 2.8.0 * @return array */ function ayg_get_source_types() { return array( 'channel' => __( 'Channel', 'automatic-youtube-gallery' ), 'playlist' => __( 'Playlist', 'automatic-youtube-gallery' ), 'username' => __( 'Username', 'automatic-youtube-gallery' ), 'search' => __( 'Search Keywords', 'automatic-youtube-gallery' ), 'livestream' => __( 'Livestream', 'automatic-youtube-gallery' ), 'video' => __( 'Single Video', 'automatic-youtube-gallery' ), 'videos' => __( 'Custom Videos List', 'automatic-youtube-gallery' ) ); } /** * Get filtered php template file path. * * @since 1.0.0 * @param array $template PHP file path. * @param string $theme Automatic YouTube Gallery Theme. * @return string Filtered file path. */ function ayg_get_template( $template, $theme = '' ) { return apply_filters( 'ayg_load_template', $template, $theme ); } /** * Get unique ID. * * @since 1.0.0 * @return string Unique ID. */ function ayg_get_uniqid() { global $ayg_uniqid; if ( ! $ayg_uniqid ) { $ayg_uniqid = 0; } return uniqid() . ++$ayg_uniqid; } /** * Get YouTube domain. * * @since 2.3.0 * @return string YouTube embed domain. */ function ayg_get_youtube_domain() { $player_settings = ayg_get_option( 'ayg_player_settings' ); $domain = 'https://www.youtube.com'; if ( isset( $player_settings['privacy_enhanced_mode'] ) && ! empty( $player_settings['privacy_enhanced_mode'] ) ) { $domain = 'https://www.youtube-nocookie.com'; } return $domain; } /** * Get the YouTube embed URL. * * @since 2.5.0 * @param string $video_id YouTube video ID. * @param array $attributes Array of user attributes. * @return string Player embed URL. */ function ayg_get_youtube_embed_url( $video_id, $attributes = array() ) { $player_settings = ayg_get_option( 'ayg_player_settings' ); $player_website = 'https://www.youtube.com'; if ( isset( $player_settings['privacy_enhanced_mode'] ) && ! empty( $player_settings['privacy_enhanced_mode'] ) ) { $player_website = 'https://www.youtube-nocookie.com'; } if ( empty( $video_id ) ) { return ''; } $url = $player_website . '/embed/' . $video_id . '?enablejsapi=1&playsinline=1&rel=0'; if ( isset( $player_settings['origin'] ) && ! empty( $player_settings['origin'] ) ) { $site_url_parts = parse_url( site_url() ); $origin = $site_url_parts['scheme'] . '://' . $site_url_parts['host']; $url = add_query_arg( 'origin', $origin, $url ); } if ( ! is_array( $attributes ) ) { $attributes = (array) $attributes; } $autoplay = isset( $attributes['autoplay'] ) ? (int) $attributes['autoplay'] : 0; if ( 1 == $autoplay ) { $url = add_query_arg( 'autoplay', 1, $url ); } $loop = isset( $attributes['loop'] ) ? (int) $attributes['loop'] : 0; if ( 1 == $loop ) { $url = add_query_arg( 'playlist', $video_id, $url ); $url = add_query_arg( 'loop', 1, $url ); } $muted = isset( $attributes['muted'] ) ? (int) $attributes['muted'] : 0; if ( 1 == $muted ) { $url = add_query_arg( 'mute', 1, $url ); } $controls = isset( $attributes['controls'] ) ? (int) $attributes['controls'] : 1; if ( 0 == $controls ) { $url = add_query_arg( 'controls', 0, $url ); } $modestbranding = isset( $attributes['modestbranding'] ) ? (int) $attributes['modestbranding'] : 0; if ( 1 == $modestbranding ) { $url = add_query_arg( 'modestbranding', 1, $url ); } $cc_load_policy = isset( $attributes['cc_load_policy'] ) ? (int) $attributes['cc_load_policy'] : 0; if ( 1 == $cc_load_policy ) { $url = add_query_arg( 'cc_load_policy', 1, $url ); } if ( isset( $attributes['hl'] ) && ! empty( $attributes['hl'] ) ) { $url = add_query_arg( 'hl', sanitize_text_field( $attributes['hl'] ), $url ); } if ( isset( $attributes['cc_lang_pref'] ) && ! empty( $attributes['cc_lang_pref'] ) ) { $url = add_query_arg( 'cc_lang_pref', sanitize_text_field( $attributes['cc_lang_pref'] ), $url ); } return apply_filters( 'ayg_youtube_embed_url', $url, $video_id, $attributes ); } /** * Resolve a YouTube video ID from a bare ID or a YouTube URL. * * @since 2.8.0 * @param string $string A video ID or YouTube URL. * @return string The 11-char video ID, or '' if none found. */ function ayg_get_youtube_video_id( $string ) { $string = trim( (string) $string ); if ( '' === $string ) { return ''; } // Already a bare video ID. if ( preg_match( '~^[A-Za-z0-9_-]{11}$~', $string ) ) { return $string; } // Common YouTube URL forms. if ( preg_match( '~(?:v=|/(?:embed|v|shorts|live)/|youtu\.be/)([A-Za-z0-9_-]{11})~', $string, $matches ) ) { return $matches[1]; } return ''; } /** * Inserts a new associative array after another associative array key. * * @since 2.1.0 * @param string $key The associative array key to insert after. * @param array $array An array to insert in to. * @param array $new_array An array to insert. * @return array Updated array. */ function ayg_insert_array_after( $key, $array, $new_array ) { if ( array_key_exists( $key, $array ) ) { $new = array(); foreach ( $array as $k => $value ) { $new[ $k ] = $value; if ( $k === $key ) { foreach ( $new_array as $new_key => $new_value ) { $new[ $new_key ] = $new_value; } } } return $new; } return $array; } /** * Detect if the client is using an iOS device (iPhone, iPad, or iPod). * * @return bool True if the user agent string suggests an iOS device, false otherwise. */ function ayg_is_ios() { if ( empty( $_SERVER['HTTP_USER_AGENT'] ) ) { return false; } $ua = strtolower( $_SERVER['HTTP_USER_AGENT'] ); if ( strpos( $ua, 'iphone' ) !== false || strpos( $ua, 'ipad' ) !== false || strpos( $ua, 'ipod' ) !== false ) { return true; } return false; } /** * Check whether a video ID matches a gallery's exclude list. * * @since 2.8.0 * @param string $video_id YouTube video ID. * @param array $exclude Exclude entries (video IDs or URLs). * @return bool True if the video is excluded. */ function ayg_is_video_excluded( $video_id, $exclude ) { if ( empty( $video_id ) || empty( $exclude ) || ! is_array( $exclude ) ) { return false; } foreach ( $exclude as $entry ) { if ( false !== strpos( $entry, $video_id ) ) { return true; } } return false; } /** * Unserialize a stored value without letting it instantiate arbitrary classes. * * Our serialized columns only ever hold the thumbnail set from an API response — plain data plus * stdClass objects — so nothing else is allowed through. This keeps a row that was tampered with * or carried over from another install from building an object of some other class. * * @since 2.9.0 * @param string $value Serialized value. * @return mixed Unserialized value, or the value unchanged when it isn't serialized. */ function ayg_maybe_unserialize( $value ) { if ( ! is_serialized( $value ) ) { return $value; } // is_serialized() trims the value before testing it, so trim here as well — otherwise a padded // value passes the test and then fails to unserialize. Mirrors core's maybe_unserialize(). $value = trim( $value ); // PHP 7.0+ can restrict this natively. if ( version_compare( PHP_VERSION, '7.0', '>=' ) ) { return @unserialize( $value, array( 'allowed_classes' => array( 'stdClass' ) ) ); } // PHP 5.6 has no "allowed_classes" option, so screen the payload instead: "O:" introduces a // plain object and "C:" a Serializable one, so refuse the value outright if it names any class // other than stdClass. Same protection as the call above, without raising the minimum PHP // version and cutting those sites off from plugin updates. if ( preg_match_all( '/(?:^|[;{])([OC]):\d+:"([^"]*)"/', $value, $matches, PREG_SET_ORDER ) ) { foreach ( $matches as $match ) { if ( 'O' !== $match[1] || 'stdClass' !== $match[2] ) { return false; } } } return @unserialize( $value ); } /** * Does this source type hand out signed pagination tokens? * * Only the sources that page through the live YouTube API do, because for those every distinct * page token means another API call. The rest either never reach the API at all (the Gallery * Builder "db" source) or page through a list already known to the site ("videos"), where the * page number is simply clamped to the real range and so cannot force extra calls. * * @since 2.9.0 * @param string $source_type Gallery source type. * @return bool True when tokens of this source type are signed. */ function ayg_page_token_is_signed( $source_type ) { return in_array( $source_type, array( 'playlist', 'channel', 'username', 'search' ), true ); } /** * Parse an ISO 8601 duration (e.g. PT4M13S) into total seconds. * * @since 2.8.0 * @param string $iso8601 ISO 8601 duration string from the YouTube API. * @return int Duration in seconds. */ function ayg_parse_duration_seconds( $iso8601 ) { if ( empty( $iso8601 ) ) { return 0; } try { $interval = new DateInterval( $iso8601 ); } catch ( Exception $e ) { return 0; } return ( $interval->d * DAY_IN_SECONDS ) + ( $interval->h * HOUR_IN_SECONDS ) + ( $interval->i * MINUTE_IN_SECONDS ) + $interval->s; } /** * Sanitize the array inputs. * * @since 2.7.0 * @param array $value Input array. * @return array Sanitized array. */ function ayg_sanitize_array( $value ) { return ! empty( $value ) ? array_map( 'sanitize_text_field', $value ) : array(); } /** * Sanitize the integer inputs, accepts empty values. * * @since 1.0.0 * @param string|int $value Input value. * @return string|int Sanitized value. */ function ayg_sanitize_int( $value ) { $value = intval( $value ); return ( 0 == $value ) ? '' : $value; } /** * Sign a pagination token before it is handed to the browser. * * Page tokens travel out to the visitor and are posted back to the public AJAX endpoint, where * each distinct token means another live call to the YouTube Data API. Signing them means only * the tokens this site actually issued are ever acted on, so a visitor cannot invent an endless * stream of new ones and burn through the site's daily API quota. * * @since 2.9.0 * @param string $token Raw page token from the API response. * @param string $uid Gallery UID the token belongs to. * @return string Signed token, or an empty string when there is no token. */ function ayg_sign_page_token( $token, $uid ) { $token = (string) $token; if ( '' === $token ) { return ''; } return $token . '.' . ayg_get_page_token_signature( $token, $uid ); } /** * Sign the pagination tokens in a page info array. * * @since 2.9.0 * @param array $page_info Page info array from the API response. * @param string $uid Gallery UID the tokens belong to. * @return array The same array with its tokens signed. */ function ayg_sign_page_tokens( $page_info, $uid ) { foreach ( array( 'next_page_token', 'prev_page_token' ) as $key ) { if ( ! empty( $page_info[ $key ] ) ) { $page_info[ $key ] = ayg_sign_page_token( $page_info[ $key ], $uid ); } } return $page_info; } /** * Trims text to a certain number of characters. * * @since 2.0.0 * @param string $text Text to trim. * @param int $num_characters Number of characters. * @param string $append String to append to the end of the excerpt. * @return string Trimmed text. */ function ayg_trim_words( $text, $num_characters, $append = '...' ) { $num_characters++; $original_text = $text; $text = ( $num_characters > 1 ) ? wp_strip_all_tags( $original_text, true ) : nl2br( $original_text ); if ( $num_characters > 1 && mb_strlen( $text ) > $num_characters ) { $subex = mb_substr( $text, 0, $num_characters - 5 ); $exwords = explode( ' ', $subex ); $excut = - ( mb_strlen( $exwords[ count( $exwords ) - 1 ] ) ); if ( $excut < 0 ) { $text = mb_substr( $subex, 0, $excut ); } else { $text = $subex; } $text .= $append; } return apply_filters( 'ayg_trim_words', $text, $original_text, $num_characters, $append ); } /** * Verify a pagination token that came back from the browser. * * @since 2.9.0 * @param string $token Signed token as posted by the browser. * @param string $uid Gallery UID the token is claimed to belong to. * @return string|false The raw token when the signature matches, false when it does not. An * empty token is the first page and needs no signature. */ function ayg_verify_page_token( $token, $uid ) { $token = (string) $token; if ( '' === $token ) { return ''; } // The signature is appended last, and a YouTube page token can itself contain almost anything, // so split on the final separator rather than the first. $position = strrpos( $token, '.' ); if ( false === $position ) { return false; } $raw = substr( $token, 0, $position ); $signature = substr( $token, $position + 1 ); if ( '' === $raw || ! hash_equals( ayg_get_page_token_signature( $raw, $uid ), $signature ) ) { return false; } return $raw; } /** * Gallery HTML output. * * @since 1.0.0 * @param array $video YouTube video object. * @param array $attributes Array of user attributes. */ function the_ayg_gallery_thumbnail( $video, $attributes ) { include ayg_get_template( AYG_DIR . 'public/templates/thumbnail.php' ); } /** * Pagination HTML output. * * @since 1.0.0 * @param array $attributes Array of user attributes. */ function the_ayg_pagination( $attributes ) { if ( ! empty( $attributes['pagination'] ) ) { include ayg_get_template( AYG_DIR . 'public/templates/pagination.php' ); } } /** * Search Form HTML output. * * @since 2.5.7 * @param array $attributes Array of user attributes. */ function the_ayg_search_form( $attributes ) { if ( ! empty( $attributes['search_form'] ) ) { include ayg_get_template( AYG_DIR . 'public/templates/search-form.php' ); } } /** * Gallery HTML output. * * @since 2.5.0 * @param array $video YouTube video object. * @param array $attributes Array of user attributes. */ function the_ayg_player( $video, $attributes ) { include ayg_get_template( AYG_DIR . 'public/templates/player.php' ); }