HEX
Server: nginx
System: Linux pool195-106-36.bur.atomicsites.net 6.12.57+deb12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.12.57-1~bpo12+1 (2025-11-17) x86_64
User: (0)
PHP: 8.3.32
Disabled: pcntl_fork
Upload Files
File: /wordpress/plugins/jetpack/16.0/jetpack_vendor/automattic/jetpack-podcast/src/class-tracks.php
<?php
/**
 * Tracks instrumentation for Jetpack Podcast.
 *
 * @package automattic/jetpack-podcast
 */

declare( strict_types = 1 );

namespace Automattic\Jetpack\Podcast;

use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
use Throwable;
use WP_Post;
use WP_Query;
use WP_User;

/**
 * Records podcast lifecycle events. Event names stay `wpcom_*` so analytics
 * queries cover Simple, Atomic, and self-hosted Jetpack feeds without a rewrite.
 * Dispatch tries `tracks_record_event` (Simple) and falls back to
 * `\Automattic\Jetpack\Tracking::tracks_record_event` (Atomic). Neither is a
 * hard dep — silently no-ops when neither is reachable.
 */
class Tracks {

	/**
	 * Wire the recorder hooks.
	 */
	public static function init(): void {
		// `wp_after_insert_post` runs after terms + meta are saved — required
		// because Gutenberg/REST publishes set terms after `transition_post_status`.
		add_action( 'wp_after_insert_post', array( __CLASS__, 'record_episode_published' ), 10, 4 );

		add_action( 'add_attachment', array( __CLASS__, 'record_media_uploaded' ) );

		add_action( 'add_option_podcasting_category_id', array( __CLASS__, 'record_category_added' ), 10, 2 );
		add_action( 'update_option_podcasting_category_id', array( __CLASS__, 'record_category_updated' ), 10, 3 );

		add_action( 'add_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_added' ), 10, 2 );
		add_action( 'update_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_updated' ), 10, 3 );

		add_action( 'jetpack_podcast_settings_saved', array( __CLASS__, 'record_settings_saved' ) );
	}

	/**
	 * Emit `wpcom_podcast_episode_published` (and `wpcom_podcast_show_launched`
	 * once per site) when a podcast-category post enters `publish`.
	 *
	 * @param int          $post_id     Post ID.
	 * @param WP_Post|null $post        Post object.
	 * @param bool         $update      Whether this is an update.
	 * @param WP_Post|null $post_before Previous post state.
	 */
	public static function record_episode_published( $post_id, $post, $update, $post_before ): void {
		unset( $post_id, $update );

		try {
			if ( ! $post instanceof WP_Post ) {
				return;
			}

			if ( 'publish' !== $post->post_status ) {
				return;
			}

			if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status ) {
				return;
			}

			if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
				return;
			}

			// @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
			if ( function_exists( 'is_headstart_post' ) && is_headstart_post( $post ) ) {
				return;
			}

			if ( in_array( $post->post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true ) ) {
				return;
			}

			$category_id = Customize_Feed::resolve_category_id();
			if ( 0 === $category_id ) {
				return;
			}

			if ( ! in_category( $category_id, $post ) ) {
				return;
			}

			// Match the RSS feed's definition of an episode — must carry
			// audio, not just sit in the podcast category.
			if ( ! self::has_podcast_media( $post ) ) {
				return;
			}

			$is_first = self::is_first_episode_for_site( $category_id, (int) $post->ID );

			self::record_event(
				'wpcom_podcast_episode_published',
				array(
					'post_id'                   => (int) $post->ID,
					'is_first_episode_for_site' => $is_first,
				),
				self::identity_for_post( $post )
			);

			// Atomic INSERT — only one concurrent caller per site wins, so
			// `show_launched` fires exactly once per site.
			if ( $is_first && add_option( 'podcast_show_launched_tracked', time(), '', false ) ) {
				self::record_event(
					'wpcom_podcast_show_launched',
					array( 'post_id' => (int) $post->ID ),
					self::identity_for_post( $post )
				);
			}
		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Tracks is best-effort — never break a publish.
		}
	}

	/**
	 * Emit `wpcom_podcast_media_uploaded` for audio/video attachments on a
	 * podcasting-enabled site.
	 *
	 * @param int $attachment_id Attachment post ID.
	 */
	public static function record_media_uploaded( $attachment_id ): void {
		try {
			if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
				return;
			}

			$attachment = get_post( (int) $attachment_id );
			// @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
			if ( $attachment && function_exists( 'is_headstart_post' ) && is_headstart_post( $attachment ) ) {
				return;
			}

			if ( 0 === Customize_Feed::resolve_category_id() ) {
				return;
			}

			$mime_type = (string) get_post_mime_type( (int) $attachment_id );
			if ( '' === $mime_type ) {
				return;
			}
			if ( 0 !== strpos( $mime_type, 'audio/' ) && 0 !== strpos( $mime_type, 'video/' ) ) {
				return;
			}

			self::record_event(
				'wpcom_podcast_media_uploaded',
				array(
					'attachment_id' => (int) $attachment_id,
					'mime_type'     => $mime_type,
				)
			);
		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Tracks is best-effort.
		}
	}

	/**
	 * `add_option_podcasting_category_id` callback — first-ever write of the
	 * option (previous value treated as 0).
	 *
	 * @param string $option Option name.
	 * @param mixed  $value  Newly stored value.
	 */
	public static function record_category_added( $option, $value ): void {
		unset( $option );

		try {
			self::maybe_record_status_change( 0, (int) $value );
		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Tracks is best-effort.
		}
	}

	/**
	 * `update_option_podcasting_category_id` callback — every change to an
	 * existing row.
	 *
	 * @param mixed  $old_value Previous stored value.
	 * @param mixed  $value     Newly stored value.
	 * @param string $option    Option name.
	 */
	public static function record_category_updated( $old_value, $value, $option ): void {
		unset( $option );

		try {
			self::maybe_record_status_change( (int) $old_value, (int) $value );
		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Tracks is best-effort.
		}
	}

	/**
	 * `add_option_podcasting_show_urls` callback. No prior row exists, so
	 * every entry is a first-time entry.
	 *
	 * @param string $option    Option name.
	 * @param mixed  $new_value Newly stored value.
	 */
	public static function record_show_url_added( $option, $new_value ): void {
		unset( $option );
		self::maybe_record_show_url_addition( array(), $new_value );
	}

	/**
	 * `update_option_podcasting_show_urls` callback. Compare the new value
	 * against the prior array to find the first directory that transitioned
	 * from absent/empty to a non-empty URL.
	 *
	 * @param mixed  $old_value Previous stored value (expected: array).
	 * @param mixed  $new_value Newly stored value.
	 * @param string $option    Option name.
	 */
	public static function record_show_url_updated( $old_value, $new_value, $option ): void {
		unset( $option );
		self::maybe_record_show_url_addition( is_array( $old_value ) ? $old_value : array(), $new_value );
	}

	/**
	 * Emit `wpcom_podcasting_show_url_saved` for the first podcatcher key
	 * that transitions from absent/empty to a non-empty string.
	 *
	 * @param array $old_value Previous map of directory => url.
	 * @param mixed $new_value Newly stored value.
	 */
	private static function maybe_record_show_url_addition( array $old_value, $new_value ): void {
		try {
			if ( ! is_array( $new_value ) ) {
				return;
			}

			foreach ( $new_value as $app => $url ) {
				if ( ! is_string( $url ) || '' === $url ) {
					continue;
				}

				$previous = isset( $old_value[ $app ] ) && is_string( $old_value[ $app ] ) ? $old_value[ $app ] : '';
				if ( '' !== $previous ) {
					continue;
				}

				self::record_event(
					'wpcom_podcasting_show_url_saved',
					array( 'app' => (string) $app )
				);
				return;
			}
		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Tracks is best-effort.
		}
	}

	/**
	 * Emit `wpcom_podcasting_settings_saved` after a podcast settings write.
	 *
	 * Fired off the `jetpack_podcast_settings_saved` action that
	 * {@see Podcast_Settings_Endpoint::update_item()} triggers, so it's agnostic
	 * to the REST transport — the endpoint already gates on a saved option.
	 */
	public static function record_settings_saved(): void {
		try {
			// Skip user-supplied free-text fields — keep PII out of tracks.
			$pii   = array( 'podcasting_email', 'podcasting_talent_name' );
			$state = array();
			foreach ( Settings::OPTION_NAMES as $name ) {
				if ( in_array( $name, $pii, true ) ) {
					continue;
				}
				$state[ $name ] = get_option( $name, '' );
			}
			self::record_event( 'wpcom_podcasting_settings_saved', $state );
		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Tracks is best-effort.
		}
	}

	/**
	 * Emit `wpcom_podcasting_status_changed` (enabled / disabled / changed)
	 * when the `podcasting_category_id` option transitions.
	 *
	 * @param int $old_value Previous category ID (0 == disabled).
	 * @param int $new_value New category ID (0 == disabled).
	 */
	private static function maybe_record_status_change( int $old_value, int $new_value ): void {
		if ( $old_value === $new_value ) {
			return;
		}

		if ( 0 === $old_value && 0 !== $new_value ) {
			$status = 'enabled';
		} elseif ( 0 !== $old_value && 0 === $new_value ) {
			$status = 'disabled';
		} else {
			$status = 'changed';
		}

		// `WPCOM_Store_API` on Simple, `Current_Plan` on Atomic — same dual
		// pattern as `Masterbar\Dashboard_Switcher_Tracking::get_plan()`.
		$plan = class_exists( '\WPCOM_Store_API' )
			? \WPCOM_Store_API::get_current_plan( (int) get_current_blog_id() )
			: ( class_exists( '\Automattic\Jetpack\Current_Plan' ) ? \Automattic\Jetpack\Current_Plan::get() : array() );

		self::record_event(
			'wpcom_podcasting_status_changed',
			array(
				'status'               => $status,
				'surface'              => 'option_write',
				'previous_category_id' => $old_value,
				'new_category_id'      => $new_value,
				'user_id'              => (int) get_current_user_id(),
				'product_slug'         => (string) ( $plan['product_slug'] ?? '' ),
			)
		);

		/** This action is documented in projects/packages/forms/src/contact-form/class-util.php */
		do_action( 'jetpack_bump_stats_extras', 'wpcom-podcasting-status', $status );
	}

	/**
	 * Identity for the publish event. Scheduled/cron publishes have no
	 * logged-in user — fall back to the post author.
	 *
	 * @param WP_Post $post Post being published.
	 */
	private static function identity_for_post( WP_Post $post ): WP_User {
		if ( ! empty( $post->post_author ) ) {
			$user = get_userdata( (int) $post->post_author );
			if ( $user instanceof WP_User ) {
				return $user;
			}
		}
		return wp_get_current_user();
	}

	/**
	 * Filters out posts in the podcast category that aren't actually episodes.
	 * `core/audio` block + classic-editor attached audio cover the supported
	 * authoring paths.
	 *
	 * @param WP_Post $post Post being checked.
	 */
	private static function has_podcast_media( WP_Post $post ): bool {
		return has_block( 'core/audio', $post )
			|| ! empty( get_attached_media( 'audio', $post->ID ) );
	}

	/**
	 * True when no other published post exists in the podcast category.
	 *
	 * @param int $category_id     Configured podcast category ID.
	 * @param int $current_post_id Post being published (excluded from the check).
	 */
	private static function is_first_episode_for_site( int $category_id, int $current_post_id ): bool {
		$existing = new WP_Query(
			array(
				'post_status'      => 'publish',
				'post_type'        => 'post',
				'cat'              => $category_id,
				'post__not_in'     => array( $current_post_id ),
				'posts_per_page'   => 1,
				'fields'           => 'ids',
				'no_found_rows'    => true,
				'suppress_filters' => true,
			)
		);

		return empty( $existing->posts );
	}

	/**
	 * Dispatch a tracks event. Auto-injects `blog_id` and defaults `$user`
	 * to the current user.
	 *
	 * @param string       $event_name Tracks event name.
	 * @param array        $properties Event properties.
	 * @param WP_User|null $user       Identity override; defaults to current user.
	 * @return mixed
	 */
	private static function record_event( string $event_name, array $properties, ?WP_User $user = null ) {
		try {
			$user                  = $user ?? wp_get_current_user();
			$properties['blog_id'] = (int) Connection_Manager::get_site_id( true );

			if ( ! function_exists( 'tracks_record_event' ) && function_exists( 'require_lib' ) ) {
				require_lib( 'tracks/client' );
			}

			if ( function_exists( 'tracks_record_event' ) ) {
				return tracks_record_event( $user, $event_name, $properties );
			}

			if ( class_exists( '\Automattic\Jetpack\Tracking' ) ) {
				return ( new \Automattic\Jetpack\Tracking() )->tracks_record_event( $user, $event_name, $properties );
			}
		} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
			// Tracks is best-effort.
		}

		return null;
	}
}