api_key = null === $api_key ? (string) YouWall_Settings::get( 'api_key' ) : (string) $api_key; } /* ── IDs ─────────────────────────────────────────────────────────── */ /** * Whether a string is a channel ID (UC + 22 characters). * * @param string $id Value. * @return bool */ public static function is_channel_id( $id ) { return (bool) preg_match( '/^UC[A-Za-z0-9_-]{22}$/', (string) $id ); } /** * Channel ID → its Shorts (UUSH…) or long videos (UULF…) playlist. * * @param string $kind 'shorts' or 'videos'. * @param string $channel_id Channel ID. * @return string */ public static function channel_playlist( $kind, $channel_id ) { return ( 'shorts' === $kind ? 'UUSH' : 'UULF' ) . substr( $channel_id, 2 ); } /** * Turn a channel ID, @handle or channel URL into a channel ID. * * @param string $input What the user typed. * @return string|WP_Error Channel ID. */ public function resolve_channel( $input ) { $input = trim( (string) $input ); if ( preg_match( '#youtube\.com/(channel/|@|c/|user/)?([^/?\#]+)#i', $input, $m ) ) { $type = strtolower( $m[1] ); $input = ( '@' === $type ) ? '@' . $m[2] : rawurldecode( $m[2] ); if ( 'user/' === $type ) { return $this->lookup_channel( array( 'forUsername' => $input ), $input ); } } if ( self::is_channel_id( $input ) ) { return $input; } $handle = ltrim( $input, '@' ); if ( ! preg_match( '/^[\p{L}\p{N}._-]{3,100}$/u', $handle ) ) { return new WP_Error( 'youwall_bad_channel', __( 'Enter a channel ID (starts with UC), a handle like @yourchannel, or the channel’s YouTube address.', 'youwall' ) ); } return $this->lookup_channel( array( 'forHandle' => '@' . $handle ), '@' . $handle ); } /** * Look up a channel ID by handle or legacy username (cached for a week). * * @param array $args channels.list arguments. * @param string $label Shown in the error message. * @return string|WP_Error */ private function lookup_channel( $args, $label ) { $key = 'youwall_ch_' . md5( wp_json_encode( $args ) ); $cached = get_transient( $key ); if ( is_string( $cached ) && self::is_channel_id( $cached ) ) { return $cached; } $body = $this->request( 'channels', array_merge( array( 'part' => 'id' ), $args ) ); if ( is_wp_error( $body ) ) { return $body; } if ( empty( $body['items'][0]['id'] ) ) { return new WP_Error( 'youwall_channel_not_found', /* translators: %s: channel handle or name the user entered. */ sprintf( __( 'No YouTube channel found for “%s”.', 'youwall' ), $label ) ); } set_transient( $key, $body['items'][0]['id'], WEEK_IN_SECONDS ); return $body['items'][0]['id']; } /* ── Feeds ───────────────────────────────────────────────────────── */ /** * One page of a playlist, newest first (cached per page). * * @param string $playlist_id Playlist ID. * @param string $kind 'shorts' or 'videos' (controls thumbnails). * @param int $per_page 1 to 50. * @param string $token YouTube page token, '' for the first page. * @return array|WP_Error { items: array, next: string } */ public function get_page( $playlist_id, $kind, $per_page, $token = '' ) { $per_page = max( 1, min( 50, absint( $per_page ) ) ); $key = $this->cache_key( 'page', $playlist_id, $kind, $per_page, $token ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return $cached; } $args = array( 'part' => 'snippet,status', 'playlistId' => $playlist_id, 'maxResults' => $per_page, ); if ( '' !== $token ) { $args['pageToken'] = $token; } $body = $this->request( 'playlistItems', $args ); if ( is_wp_error( $body ) ) { $data = $body->get_error_data(); // A channel with no Shorts (or no long videos) has no UUSH/UULF playlist yet. $derived = 0 === strpos( $playlist_id, 'UUSH' ) || 0 === strpos( $playlist_id, 'UULF' ); if ( $derived && isset( $data['status'] ) && 404 === $data['status'] ) { $result = array( 'items' => array(), 'next' => '', ); set_transient( $key, $result, YouWall_Settings::cache_seconds() ); return $result; } return $body; } $items = array(); foreach ( isset( $body['items'] ) ? $body['items'] : array() as $row ) { if ( isset( $row['status']['privacyStatus'] ) && 'public' !== $row['status']['privacyStatus'] ) { continue; } $id = isset( $row['snippet']['resourceId']['videoId'] ) ? (string) $row['snippet']['resourceId']['videoId'] : ''; if ( ! preg_match( '/^[A-Za-z0-9_-]{11}$/', $id ) ) { continue; } $items[ $id ] = array( 'id' => $id, 'title' => isset( $row['snippet']['title'] ) ? (string) $row['snippet']['title'] : '', 'published' => isset( $row['snippet']['publishedAt'] ) ? (string) $row['snippet']['publishedAt'] : '', 'thumbnail' => self::thumbnail( $kind, $id, isset( $row['snippet']['thumbnails'] ) ? $row['snippet']['thumbnails'] : array() ), 'views' => null, 'duration' => 0, ); } $items = $this->add_stats( $items ); $result = array( 'items' => array_values( $items ), 'next' => isset( $body['nextPageToken'] ) ? (string) $body['nextPageToken'] : '', ); set_transient( $key, $result, YouWall_Settings::cache_seconds() ); return $result; } /** * Check a key and channel before saving. * * @param string $channel Channel ID, handle or URL. * @return array|WP_Error { channel_id, title } */ public function test_connection( $channel ) { $channel_id = $this->resolve_channel( $channel ); if ( is_wp_error( $channel_id ) ) { return $channel_id; } $body = $this->request( 'channels', array( 'part' => 'snippet', 'id' => $channel_id, ) ); if ( is_wp_error( $body ) ) { return $body; } if ( empty( $body['items'][0] ) ) { return new WP_Error( 'youwall_channel_not_found', __( 'The API key works, but this channel was not found.', 'youwall' ) ); } return array( 'channel_id' => $channel_id, 'title' => isset( $body['items'][0]['snippet']['title'] ) ? (string) $body['items'][0]['snippet']['title'] : $channel_id, ); } /* ── Helpers ─────────────────────────────────────────────────────── */ /** * Add view counts and durations (one videos.list call per page). * * @param array $items Items keyed by video ID. * @return array */ private function add_stats( $items ) { if ( empty( $items ) ) { return $items; } $body = $this->request( 'videos', array( 'part' => 'statistics,contentDetails', 'id' => implode( ',', array_keys( $items ) ), ) ); if ( is_wp_error( $body ) || empty( $body['items'] ) ) { return $items; } foreach ( $body['items'] as $row ) { $id = isset( $row['id'] ) ? $row['id'] : ''; if ( ! isset( $items[ $id ] ) ) { continue; } if ( isset( $row['statistics']['viewCount'] ) ) { $items[ $id ]['views'] = (int) $row['statistics']['viewCount']; } if ( isset( $row['contentDetails']['duration'] ) ) { $items[ $id ]['duration'] = self::duration_seconds( $row['contentDetails']['duration'] ); } } return $items; } /** * Thumbnail URL. Shorts use YouTube's portrait cover; videos use the largest * size the API says exists (maxresdefault is missing for many videos). * * @param string $kind 'shorts' or 'videos'. * @param string $id Video ID. * @param array $thumbnails snippet.thumbnails. * @return string */ private static function thumbnail( $kind, $id, $thumbnails ) { if ( 'shorts' === $kind ) { return 'https://i.ytimg.com/vi/' . $id . '/oar2.jpg'; } foreach ( array( 'maxres', 'standard', 'high', 'medium', 'default' ) as $size ) { if ( ! empty( $thumbnails[ $size ]['url'] ) ) { return (string) $thumbnails[ $size ]['url']; } } return 'https://i.ytimg.com/vi/' . $id . '/hqdefault.jpg'; } /** * Call the API. * * @param string $endpoint API endpoint, e.g. 'playlistItems'. * @param array $args Query arguments. * @return array|WP_Error Decoded body. */ private function request( $endpoint, $args ) { if ( '' === $this->api_key ) { return new WP_Error( 'youwall_no_key', __( 'Add your YouTube API key in YouWall > Connect.', 'youwall' ) ); } $args['key'] = $this->api_key; $response = wp_remote_get( add_query_arg( array_map( 'rawurlencode', $args ), self::BASE . $endpoint ), array( 'timeout' => 15 ) ); if ( is_wp_error( $response ) ) { return new WP_Error( 'youwall_http', /* translators: %s: technical error message. */ sprintf( __( 'Could not reach YouTube: %s', 'youwall' ), $response->get_error_message() ) ); } $code = (int) wp_remote_retrieve_response_code( $response ); $body = json_decode( wp_remote_retrieve_body( $response ), true ); if ( 200 === $code && is_array( $body ) ) { return $body; } $reason = isset( $body['error']['errors'][0]['reason'] ) ? (string) $body['error']['errors'][0]['reason'] : ''; $known = array( 'keyInvalid' => __( 'The YouTube API key is not valid. Check it in YouWall > Connect.', 'youwall' ), 'badRequest' => __( 'The YouTube API key is not valid. Check it in YouWall > Connect.', 'youwall' ), 'quotaExceeded' => __( 'Today’s YouTube API quota is used up. The feed will work again after it resets (midnight Pacific Time).', 'youwall' ), 'accessNotConfigured' => __( 'YouTube Data API v3 is not enabled for this key. Enable it in Google Cloud Console.', 'youwall' ), 'forbidden' => __( 'YouTube refused the request. Check the key’s restrictions in Google Cloud Console.', 'youwall' ), 'playlistNotFound' => __( 'This playlist was not found, or it is private.', 'youwall' ), ); if ( isset( $known[ $reason ] ) ) { $message = $known[ $reason ]; } elseif ( ! empty( $body['error']['message'] ) ) { $message = wp_strip_all_tags( (string) $body['error']['message'] ); } else { /* translators: %d: HTTP status code. */ $message = sprintf( __( 'YouTube returned error %d.', 'youwall' ), $code ); } return new WP_Error( 'youwall_api', $message, array( 'status' => $code, 'reason' => $reason, ) ); } /** * Cache key that includes the cache version, so "Clear cache" is one option update. * * @param mixed ...$parts Key parts. * @return string */ private function cache_key( ...$parts ) { return 'youwall_' . md5( YouWall_Settings::cache_version() . '|' . wp_json_encode( $parts ) ); } /** * ISO 8601 duration (e.g. P1DT2H3M4S) → seconds. * * @param string $iso Duration. * @return int */ public static function duration_seconds( $iso ) { if ( ! preg_match( '/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/', (string) $iso, $m ) ) { return 0; } $m = array_map( 'intval', array_pad( $m, 5, 0 ) ); return $m[1] * DAY_IN_SECONDS + $m[2] * HOUR_IN_SECONDS + $m[3] * MINUTE_IN_SECONDS + $m[4]; } /** * Seconds → "4:05" or "1:02:03". * * @param int $seconds Seconds. * @return string */ public static function format_duration( $seconds ) { $seconds = absint( $seconds ); if ( ! $seconds ) { return ''; } $h = intdiv( $seconds, 3600 ); $m = intdiv( $seconds % 3600, 60 ); $s = $seconds % 60; return $h ? sprintf( '%d:%02d:%02d', $h, $m, $s ) : sprintf( '%d:%02d', $m, $s ); } /** * View count → "1.2K views". * * @param int|null $views View count. * @return string */ public static function format_views( $views ) { if ( null === $views ) { return ''; } $n = (int) $views; if ( $n >= 1000000 ) { $short = rtrim( rtrim( number_format_i18n( $n / 1000000, 1 ), '0' ), '.,' ) . 'M'; } elseif ( $n >= 1000 ) { $short = rtrim( rtrim( number_format_i18n( $n / 1000, 1 ), '0' ), '.,' ) . 'K'; } else { $short = number_format_i18n( $n ); } /* translators: %s: view count, for example "1.2K". */ return sprintf( _n( '%s view', '%s views', $n, 'youwall' ), $short ); } /** * Publish date → "3 days ago". * * @param string $iso ISO date. * @return string */ public static function time_ago( $iso ) { $ts = $iso ? strtotime( $iso ) : false; if ( ! $ts ) { return ''; } /* translators: %s: time span, for example "3 days". */ return sprintf( __( '%s ago', 'youwall' ), human_time_diff( $ts ) ); } }