diff --git a/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php
new file mode 100644
index 000000000000..f7208f728af1
--- /dev/null
+++ b/projects/plugins/jetpack/_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php
@@ -0,0 +1,332 @@
+ '/wpcom/v2/posts-to-podcast',
+ 'jobsPath' => '/wpcom/v2/posts-to-podcast/jobs/',
+ 'voicePresets' => Jetpack_Posts_To_Podcast_Helper::get_voice_presets(),
+ 'lengthPresets' => Jetpack_Posts_To_Podcast_Helper::get_length_presets(),
+ 'windowPresets' => Jetpack_Posts_To_Podcast_Helper::get_window_presets(),
+ 'pollFastMs' => 3000,
+ 'pollSlowMs' => 10000,
+ 'pollSwitchMs' => 30000,
+ 'pollTimeoutMs' => 5 * 60 * 1000,
+ 'newPostBaseUrl' => admin_url( 'post-new.php' ),
+ 'editPostUrl' => admin_url( 'post.php' ),
+ 'i18n' => array(
+ 'generate' => __( 'Generate', 'jetpack' ),
+ 'generating' => __( 'Generating…', 'jetpack' ),
+ 'queueFailed' => __( 'Failed to queue the generation. Please try again.', 'jetpack' ),
+ 'pollFailed' => __( 'Failed to read job status. Please try again.', 'jetpack' ),
+ /* translators: %s: error message returned by the generation pipeline. */
+ 'jobFailed' => __( 'Generation failed: %s', 'jetpack' ),
+ 'noContentNoPosts' => __( 'No posts published in this window — try a longer one.', 'jetpack' ),
+ 'noContentNoThreshold' => __( 'Posts in this window did not pass the relevance threshold. Try a longer window.', 'jetpack' ),
+ 'draftCreated' => __( 'Draft created. Opening the editor…', 'jetpack' ),
+ 'draftCreateFailed' => __( 'Generation completed but the draft post could not be created.', 'jetpack' ),
+ 'pendingStatus' => __( 'Pending — your job is queued.', 'jetpack' ),
+ 'runningStatus' => __( 'Running — generating your script. This usually takes 30–90 seconds.', 'jetpack' ),
+ ),
+ );
+
+ wp_add_inline_script(
+ 'wp-api-fetch',
+ 'window.jetpackPostsToPodcast = ' . wp_json_encode( $bootstrap, JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP ) . ';',
+ 'after'
+ );
+
+ wp_add_inline_script( 'wp-api-fetch', $this->get_inline_app_script(), 'after' );
+ }
+
+ /**
+ * Render the form into the page body. wrap_ui() in the parent class supplies
+ * the Jetpack masthead/footer; we only render the page-specific markup here.
+ */
+ public function page_render() {
+ $presets = Jetpack_Posts_To_Podcast_Helper::get_window_presets();
+ $lengths = Jetpack_Posts_To_Podcast_Helper::get_length_presets();
+ $voices = Jetpack_Posts_To_Podcast_Helper::get_voice_presets();
+ ?>
+
+ settings.pollTimeoutMs ) {
+ setStatus( '' + settings.i18n.pollFailed + '
' );
+ setBusy( false );
+ return;
+ }
+ var nextDelay = elapsed < settings.pollSwitchMs ? settings.pollFastMs : settings.pollSlowMs;
+ apiFetch( { path: settings.jobsPath + encodeURIComponent( jobId ) } )
+ .then( function ( record ) {
+ if ( record.status === 'pending' ) {
+ setStatus( '' + settings.i18n.pendingStatus + '
' );
+ setTimeout( function () { pollJob( jobId, startedAt ); }, nextDelay );
+ return;
+ }
+ if ( record.status === 'running' ) {
+ setStatus( '' + settings.i18n.runningStatus + '
' );
+ setTimeout( function () { pollJob( jobId, startedAt ); }, nextDelay );
+ return;
+ }
+ if ( record.status === 'failed' ) {
+ var msg = record.errorMessage || record.errorCode || 'unknown';
+ setStatus( '' + settings.i18n.jobFailed.replace( '%s', msg ) + '
' );
+ setBusy( false );
+ return;
+ }
+ if ( record.status === 'complete' ) {
+ if ( ! record.script ) {
+ var reason = record.metadata && record.metadata.noContentReason;
+ var message = reason === 'no-items-above-threshold'
+ ? settings.i18n.noContentNoThreshold
+ : settings.i18n.noContentNoPosts;
+ setStatus( '' + message + '
' );
+ setBusy( false );
+ return;
+ }
+ createDraft( record );
+ return;
+ }
+ setStatus( '' + settings.i18n.pollFailed + '
' );
+ setBusy( false );
+ } )
+ .catch( function () {
+ setStatus( '' + settings.i18n.pollFailed + '
' );
+ setBusy( false );
+ } );
+ }
+
+ function buildPostMeta( record ) {
+ var m = record.metadata || {};
+ return {
+ '_p2p_format': m.format || 'two-voice-dialogue',
+ '_p2p_voices': m.voices || [],
+ '_p2p_themes': m.themes || [],
+ '_p2p_source_post_ids': m.sourcePostIds || [],
+ '_p2p_window': m.window || record.window || {},
+ '_p2p_generated_at': m.generatedAt || record.completedAt || '',
+ '_p2p_warnings': ( m.warnings && m.warnings.length ) ? m.warnings : undefined,
+ };
+ }
+
+ function createDraft( record ) {
+ var m = record.metadata || {};
+ var body = {
+ status: 'draft',
+ title: m.title || 'Posts to Podcast — episode',
+ content: record.script,
+ meta: buildPostMeta( record ),
+ };
+ apiFetch( { path: '/wp/v2/posts', method: 'POST', data: body } )
+ .then( function ( draft ) {
+ setStatus( '' + settings.i18n.draftCreated + '
' );
+ var url = settings.editPostUrl + '?action=edit&post=' + encodeURIComponent( draft.id );
+ window.location.href = url;
+ } )
+ .catch( function () {
+ setStatus( '' + settings.i18n.draftCreateFailed + '
' );
+ setBusy( false );
+ } );
+ }
+
+ function onGenerate() {
+ setBusy( true );
+ setStatus( '' );
+ var win = buildWindow();
+ if ( ! win ) {
+ setStatus( '' + settings.i18n.queueFailed + '
' );
+ setBusy( false );
+ return;
+ }
+ var body = {
+ window: win,
+ length: $( 'jp-p2p-length' ).value,
+ voicePreset: $( 'jp-p2p-voice' ).value,
+ };
+ apiFetch( { path: settings.apiPath, method: 'POST', data: body } )
+ .then( function ( resp ) {
+ if ( ! resp || ! resp.jobId ) {
+ setStatus( '' + settings.i18n.queueFailed + '
' );
+ setBusy( false );
+ return;
+ }
+ setStatus( '' + settings.i18n.pendingStatus + '
' );
+ pollJob( resp.jobId, Date.now() );
+ } )
+ .catch( function () {
+ setStatus( '' + settings.i18n.queueFailed + '
' );
+ setBusy( false );
+ } );
+ }
+
+ document.addEventListener( 'DOMContentLoaded', function () {
+ var btn = document.getElementById( 'jp-p2p-generate' );
+ if ( btn ) {
+ btn.addEventListener( 'click', onGenerate );
+ }
+ } );
+} )();
+JS;
+ // phpcs:enable
+ }
+}
diff --git a/projects/plugins/jetpack/_inc/lib/class-jetpack-posts-to-podcast-helper.php b/projects/plugins/jetpack/_inc/lib/class-jetpack-posts-to-podcast-helper.php
new file mode 100644
index 000000000000..37bb68e73d28
--- /dev/null
+++ b/projects/plugins/jetpack/_inc/lib/class-jetpack-posts-to-podcast-helper.php
@@ -0,0 +1,144 @@
+
+ */
+ public static function get_voice_presets() {
+ return array(
+ array(
+ 'id' => 'witty',
+ 'label' => __( 'Witty', 'jetpack' ),
+ ),
+ array(
+ 'id' => 'earnest',
+ 'label' => __( 'Earnest', 'jetpack' ),
+ ),
+ array(
+ 'id' => 'professional',
+ 'label' => __( 'Professional', 'jetpack' ),
+ ),
+ );
+ }
+
+ /**
+ * Length presets surfaced to the admin UI.
+ *
+ * @return array
+ */
+ public static function get_length_presets() {
+ return array(
+ array(
+ 'id' => 'short',
+ 'label' => __( 'Short (~3 min)', 'jetpack' ),
+ ),
+ array(
+ 'id' => 'medium',
+ 'label' => __( 'Medium (~7 min)', 'jetpack' ),
+ ),
+ array(
+ 'id' => 'long',
+ 'label' => __( 'Long (~12 min)', 'jetpack' ),
+ ),
+ );
+ }
+
+ /**
+ * Window quick-picks surfaced to the admin UI. The relative-form `unit/n` shape
+ * matches the wpcom endpoint contract; the absolute `from/to` form is also accepted
+ * by the endpoint but is built from the date inputs in the UI.
+ *
+ * @return array
+ */
+ public static function get_window_presets() {
+ return array(
+ array(
+ 'id' => 'last-7-days',
+ 'label' => __( 'Last 7 days', 'jetpack' ),
+ 'unit' => 'days',
+ 'n' => 7,
+ ),
+ array(
+ 'id' => 'last-14-days',
+ 'label' => __( 'Last 14 days', 'jetpack' ),
+ 'unit' => 'days',
+ 'n' => 14,
+ ),
+ array(
+ 'id' => 'last-30-days',
+ 'label' => __( 'Last 30 days', 'jetpack' ),
+ 'unit' => 'days',
+ 'n' => 30,
+ ),
+ array(
+ 'id' => 'last-3-months',
+ 'label' => __( 'Last 3 months', 'jetpack' ),
+ 'unit' => 'months',
+ 'n' => 3,
+ ),
+ );
+ }
+}
diff --git a/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-posts-to-podcast.php b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-posts-to-podcast.php
new file mode 100644
index 000000000000..f696878389ad
--- /dev/null
+++ b/projects/plugins/jetpack/_inc/lib/core-api/wpcom-endpoints/class-wpcom-rest-api-v2-endpoint-posts-to-podcast.php
@@ -0,0 +1,208 @@
+is_wpcom = true;
+ $this->wpcom_is_wpcom_only_endpoint = false;
+
+ if ( ! Jetpack_Posts_To_Podcast_Helper::is_enabled() ) {
+ return;
+ }
+
+ add_action( 'rest_api_init', array( $this, 'register_routes' ) );
+ }
+
+ /**
+ * Register routes.
+ */
+ public function register_routes() {
+ register_rest_route(
+ $this->namespace,
+ $this->rest_base,
+ array(
+ array(
+ 'methods' => WP_REST_Server::CREATABLE,
+ 'callback' => array( $this, 'enqueue_generation' ),
+ 'permission_callback' => array( 'Jetpack_Posts_To_Podcast_Helper', 'get_status_permission_check' ),
+ 'args' => $this->get_enqueue_args(),
+ ),
+ )
+ );
+
+ register_rest_route(
+ $this->namespace,
+ $this->rest_base . '/jobs/(?P[a-f0-9\-]+)',
+ array(
+ array(
+ 'methods' => WP_REST_Server::READABLE,
+ 'callback' => array( $this, 'read_job_status' ),
+ 'permission_callback' => array( 'Jetpack_Posts_To_Podcast_Helper', 'get_status_permission_check' ),
+ 'args' => array(
+ 'job_id' => array(
+ 'type' => 'string',
+ 'required' => true,
+ ),
+ ),
+ ),
+ )
+ );
+ }
+
+ /**
+ * Argument schema for the POST route.
+ *
+ * @return array>
+ */
+ private function get_enqueue_args() {
+ return array(
+ 'window' => array(
+ 'type' => 'object',
+ 'required' => true,
+ 'description' => __( 'Either { unit: days|weeks|months, n: } or { from, to } as ISO-8601 dates.', 'jetpack' ),
+ ),
+ 'length' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'enum' => wp_list_pluck( Jetpack_Posts_To_Podcast_Helper::get_length_presets(), 'id' ),
+ 'description' => __( 'Length preset id.', 'jetpack' ),
+ ),
+ 'voicePreset' => array(
+ 'type' => 'string',
+ 'required' => true,
+ 'enum' => wp_list_pluck( Jetpack_Posts_To_Podcast_Helper::get_voice_presets(), 'id' ),
+ 'description' => __( 'Voice preset id.', 'jetpack' ),
+ ),
+ );
+ }
+
+ /**
+ * Forward POST to the wpcom-side endpoint and return the queued job descriptor.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ *
+ * @return WP_REST_Response|WP_Error
+ */
+ public function enqueue_generation( WP_REST_Request $request ) {
+ $blog_id = (int) Jetpack_Options::get_option( 'id' );
+ if ( ! $blog_id ) {
+ return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack' ), array( 'status' => 400 ) );
+ }
+
+ $body = array(
+ 'window' => $request->get_param( 'window' ),
+ 'length' => $request->get_param( 'length' ),
+ 'voicePreset' => $request->get_param( 'voicePreset' ),
+ );
+
+ $response = Client::wpcom_json_api_request_as_blog(
+ sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
+ 2,
+ array(
+ 'method' => 'POST',
+ 'headers' => array( 'content-type' => 'application/json' ),
+ 'timeout' => 30,
+ ),
+ wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
+ 'wpcom'
+ );
+
+ return $this->relay_response( $response );
+ }
+
+ /**
+ * Forward GET to the wpcom-side polling endpoint and return the job record.
+ *
+ * @param WP_REST_Request $request Full details about the request.
+ *
+ * @return WP_REST_Response|WP_Error
+ */
+ public function read_job_status( WP_REST_Request $request ) {
+ $blog_id = (int) Jetpack_Options::get_option( 'id' );
+ if ( ! $blog_id ) {
+ return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack' ), array( 'status' => 400 ) );
+ }
+
+ $job_id = (string) $request['job_id'];
+
+ $response = Client::wpcom_json_api_request_as_blog(
+ sprintf( '/sites/%d/posts-to-podcast/jobs/%s', $blog_id, rawurlencode( $job_id ) ),
+ 2,
+ array(
+ 'method' => 'GET',
+ 'headers' => array( 'content-type' => 'application/json' ),
+ 'timeout' => 15,
+ ),
+ null,
+ 'wpcom'
+ );
+
+ return $this->relay_response( $response );
+ }
+
+ /**
+ * Relay an upstream Connection\Client response back to the local REST client.
+ * Preserves the upstream HTTP status code so 202/4xx/5xx mappings flow through.
+ *
+ * @param array|WP_Error $response The raw response from Client::wpcom_json_api_request_as_blog.
+ *
+ * @return WP_REST_Response|WP_Error
+ */
+ private function relay_response( $response ) {
+ if ( is_wp_error( $response ) ) {
+ return $response;
+ }
+
+ $code = (int) wp_remote_retrieve_response_code( $response );
+ $body = wp_remote_retrieve_body( $response );
+ $decoded = json_decode( $body, true );
+
+ $rest_response = rest_ensure_response( null === $decoded ? $body : $decoded );
+ if ( $code >= 100 && $code < 600 ) {
+ $rest_response->set_status( $code );
+ }
+ return $rest_response;
+ }
+}
+
+wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Posts_To_Podcast' );
diff --git a/projects/plugins/jetpack/changelog/add-posts-to-podcast b/projects/plugins/jetpack/changelog/add-posts-to-podcast
new file mode 100644
index 000000000000..40e4a9a56648
--- /dev/null
+++ b/projects/plugins/jetpack/changelog/add-posts-to-podcast
@@ -0,0 +1,4 @@
+Significance: minor
+Type: other
+
+Added a post to podcast base feature
diff --git a/projects/plugins/jetpack/class.jetpack-admin.php b/projects/plugins/jetpack/class.jetpack-admin.php
index 3c2f8bb3224d..c93193d0051f 100644
--- a/projects/plugins/jetpack/class.jetpack-admin.php
+++ b/projects/plugins/jetpack/class.jetpack-admin.php
@@ -66,6 +66,9 @@ private function __construct() {
require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-ai-page.php';
$jetpack_ai = new Jetpack_AI_Page();
+ require_once JETPACK__PLUGIN_DIR . '_inc/lib/admin-pages/class-jetpack-posts-to-podcast-page.php';
+ $jetpack_posts_to_podcast = new Jetpack_Posts_To_Podcast_Page();
+
add_action( 'admin_init', array( $jetpack_react, 'react_redirects' ), 0 );
add_action( 'admin_menu', array( $jetpack_react, 'add_actions' ), 998 );
add_action( 'admin_menu', array( $jetpack_react, 'remove_jetpack_menu' ), 2000 );
@@ -74,6 +77,7 @@ private function __construct() {
add_action( 'jetpack_admin_menu', array( $fallback_page, 'add_actions' ) );
add_action( 'jetpack_admin_menu', array( $jetpack_about, 'add_actions' ) );
add_action( 'jetpack_admin_menu', array( $jetpack_ai, 'add_actions' ) );
+ add_action( 'jetpack_admin_menu', array( $jetpack_posts_to_podcast, 'add_actions' ) );
// Add redirect to current page for activation/deactivation of modules.
add_action( 'jetpack_pre_activate_module', array( $this, 'fix_redirect' ), 10, 2 );